7 Commits

Author SHA1 Message Date
s1d3sw1ped_bot 45778ac528 Default local listeners to 127.0.0.1 (#7)
CI / check-and-test (push) Successful in 11s
Release Tag / release (push) Successful in 12s
Client TCP/UDP and the built-in DNS server bind 127.0.0.1 unless bind_address or a per-rule host is set.

Closes #3
2026-08-31 23:13:43 -05:00
s1d3sw1ped_bot c20398642d Default local listeners to 127.0.0.1
CI / check-and-test (pull_request) Successful in 11s
Client TCP/UDP and the built-in DNS server bind loopback unless bind_address or a per-rule host (tcp://22:0.0.0.0:2222) is set. README and generated server examples no longer document :9000/:8080 as if they were localhost-only.

Closes #3
2026-09-01 04:10:14 +00:00
s1d3sw1ped_bot 283781fd47 Encrypt TCP tunnel payload with AES-GCM (#6)
CI / check-and-test (push) Failing after 10s
2026-08-31 22:30:40 -05:00
s1d3sw1ped_bot 1fef0b6a70 Bump Go to 1.27.0 (#5)
CI / check-and-test (push) Successful in 12s
2026-08-31 20:02:00 -05:00
s1d3sw1ped_bot 6d259180fd Run CI on push to master and bump Go to 1.26.0 (#1)
CI / check-and-test (push) Successful in 11s
Also serialize logger.Init vs GetLogger so -race is green.
2026-08-31 18:59:18 -05:00
s1d3sw1ped_bot fa89d7c126 Fix data race between logger.Init and GetLogger.
CI / check-and-test (pull_request) Successful in 20s
Copy the global logger pointer under the mutex and publish a fully built instance so concurrent tests and handleConnection logging are race-free.
2026-08-31 23:56:43 +00:00
s1d3sw1ped_bot e12142633a Run CI on push to master and bump Go to 1.26.0
CI / check-and-test (pull_request) Failing after 20s
Match steamcache2: trigger check-and-test on pull_request and push to the default branch. Tests passed on 1.26.0.
2026-08-31 21:13:07 +00:00
13 changed files with 952 additions and 90 deletions
+5 -2
View File
@@ -1,6 +1,9 @@
name: PR Check
name: CI
on:
- pull_request
pull_request:
push:
branches:
- master
jobs:
check-and-test:
+39 -25
View File
@@ -34,7 +34,7 @@ Flow: User → Client:8080,2222 → Encrypted Tunnel (Port 9000) → Server:80,2
**Server Configuration** (`server.yaml`):
```yaml
instance_id: teleport-server-01
listen_address: :9000
listen_address: 127.0.0.1:9000 # loopback; use 0.0.0.0:9000 to publish
remote_address: ""
ports:
- "tcp://192.168.1.100:80"
@@ -50,6 +50,7 @@ write_timeout: 30s
instance_id: teleport-client-01
listen_address: ""
remote_address: server.example.com:9000
bind_address: 127.0.0.1
ports:
- "tcp://80:8080"
- "tcp://22:2222"
@@ -60,8 +61,10 @@ write_timeout: 30s
```
**Usage**:
- User connects to `localhost:8080` → Traffic flows through encrypted tunnel on port 9000 → Server forwards to remote web server at `192.168.1.100:80`
- User connects to `localhost:2222` → Traffic flows through same encrypted tunnel on port 9000 → Server forwards to remote SSH server at `192.168.1.200:22`
- User connects to `127.0.0.1:8080` (client local listener, loopback by default) → Traffic flows through encrypted tunnel on `127.0.0.1:9000` → Server forwards to remote web server at `192.168.1.100:80`
- User connects to `127.0.0.1:2222` → Traffic flows through the same encrypted tunnel → Server forwards to remote SSH server at `192.168.1.200:22`
Local client ports bind `127.0.0.1` unless you set `bind_address: 0.0.0.0` or a per-rule host such as `"tcp://22:0.0.0.0:2222"`. Server `listen_address: 127.0.0.1:9000` is loopback-only; `0.0.0.0:9000` (or `:9000`) publishes the tunnel on all interfaces and must be explicit.
Both services share the same encrypted tunnel connection!
@@ -116,7 +119,7 @@ Generate example configuration files:
```yaml
instance_id: teleport-server-01
listen_address: :8080
listen_address: 127.0.0.1:8080 # use 0.0.0.0:8080 to accept remote clients
remote_address: ""
ports:
- "tcp://localhost:80"
@@ -143,6 +146,7 @@ dns_server:
instance_id: teleport-client-01
listen_address: ""
remote_address: localhost:8080
bind_address: 127.0.0.1
ports:
- "tcp://80:8080"
encryption_key: your-secure-encryption-key-change-this-to-something-random
@@ -169,16 +173,19 @@ dns_server:
## Configuration Fields
- `instance_id`: Unique identifier for this teleport instance
- `listen_address`: Address to listen on (server mode) - format: `host:port`
- `listen_address`: Address the **server** binds for the encrypted tunnel (`host:port`). Examples use `127.0.0.1:9000`. Use `0.0.0.0:9000` or `:9000` only when you intend to publish the server on all interfaces.
- `remote_address`: Address of remote teleport server (client mode) - format: `host:port`
- `bind_address`: Host for **client** local TCP/UDP listeners (default: `127.0.0.1`). Set to `0.0.0.0` to accept connections from other machines. Publishing on all interfaces is never implicit.
- `ports`: Array of port forwarding rules in URL-style format
- **Server format**: `protocol://target:targetport` - forwards to remote target
- **Client format**: `protocol://targetport:localport` - forwards to teleport server's targetport, listens on localport
- **Client format**: `protocol://targetport:localport` - forwards to teleport server's targetport, listens on `bind_address:localport` (default `127.0.0.1`)
- **Client with bind**: `protocol://targetport:bindhost:localport` - per-rule listen host, e.g. `"tcp://22:0.0.0.0:2222"`
- Examples:
- `"tcp://localhost:80"` (server) - listen on port 80, forward to localhost:80
- `"tcp://server-a:22"` (server) - listen on port 22, forward to server-a:22
- `"tcp://80:8080"` (client) - listen on port 8080, forward to teleport server's port 80
- `"udp://53:5353"` (client) - listen on port 5353, forward to teleport server's port 53
- `"tcp://localhost:80"` (server) - forward to localhost:80
- `"tcp://server-a:22"` (server) - forward to server-a:22
- `"tcp://80:8080"` (client) - listen on `127.0.0.1:8080`, forward to teleport server's port 80
- `"tcp://22:0.0.0.0:2222"` (client) - listen on all interfaces port 2222
- `"udp://53:5353"` (client) - listen on `127.0.0.1:5353`, forward to teleport server's port 53
- `encryption_key`: Shared secret key for encryption (must be the same on both sides)
- `keep_alive`: Enable TCP keep-alive
- `read_timeout`: Read timeout duration
@@ -192,6 +199,7 @@ dns_server:
- `dns_server`: DNS server configuration
- `enabled`: Enable built-in DNS server
- `listen_port`: Port for DNS server to listen on
- `bind_address`: DNS listen host (default: `127.0.0.1`). Set to `0.0.0.0` to serve DNS on all interfaces; the default is not an open recursive forwarder on every interface.
- `backup_server`: Backup DNS server for fallback (e.g., "8.8.8.8:53")
- `custom_records`: Array of custom DNS records
- `name`: Domain name (e.g., "example.com")
@@ -278,14 +286,15 @@ Teleport uses URL-style port mapping format for cleaner configuration:
```yaml
ports:
- "tcp://80:8080" # Listen on port 8080, forward to teleport server's port 80
- "udp://53:5353" # Listen on port 5353, forward to teleport server's port 53
- "tcp://22:2222" # Listen on port 2222, forward to teleport server's port 22
- "tcp://80:8080" # Listen on 127.0.0.1:8080, forward to teleport server's port 80
- "udp://53:5353" # Listen on 127.0.0.1:5353, forward to teleport server's port 53
- "tcp://22:0.0.0.0:2222" # Explicit all-interfaces bind
```
**Format**:
- **Server**: `"protocol://target:targetport"` - listen on targetport, forward to target:targetport
- **Client**: `"protocol://targetport:localport"` - listen on localport, forward to teleport server's targetport
- **Server**: `"protocol://target:targetport"` - forward to target:targetport
- **Client**: `"protocol://targetport:localport"` - listen on `127.0.0.1:localport` (or `bind_address`), forward to teleport server's targetport
- **Client with bind**: `"protocol://targetport:bindhost:localport"` - listen on bindhost:localport
**Examples:**
```yaml
@@ -297,9 +306,10 @@ ports:
# Client configurations
ports:
- "tcp://80:8080" # Listen on port 8080, forward to teleport server's port 80
- "tcp://22:2222" # Listen on port 2222, forward to teleport server's port 22
- "udp://53:5353" # Listen on port 5353, forward to teleport server's port 53
- "tcp://80:8080" # Listen on 127.0.0.1:8080, forward to teleport server's port 80
- "tcp://22:2222" # Listen on 127.0.0.1:2222, forward to teleport server's port 22
- "tcp://22:0.0.0.0:2222" # Listen on all interfaces (explicit)
- "udp://53:5353" # Listen on 127.0.0.1:5353, forward to teleport server's port 53
```
**Example: Remote SSH Access**
@@ -308,10 +318,10 @@ To access SSH on Server A through teleport server on Server B:
**Server B configuration:**
```yaml
instance_id: teleport-server-b
listen_address: :8080
listen_address: 127.0.0.1:8080
remote_address: ""
ports:
- "tcp://server-a:22" # Listen on port 22, forward to server-a:22
- "tcp://server-a:22" # Forward to server-a:22
encryption_key: your-shared-key
```
@@ -320,8 +330,9 @@ encryption_key: your-shared-key
instance_id: teleport-client-c
listen_address: ""
remote_address: server-b:8080
bind_address: 127.0.0.1
ports:
- "tcp://22:2222" # Listen on local port 2222, forward to teleport server's port 22
- "tcp://22:2222" # Listen on 127.0.0.1:2222, forward to teleport server's port 22
encryption_key: your-shared-key
```
@@ -345,7 +356,7 @@ The program validates your configuration and will show clear error messages if:
**Valid Server Configuration:**
```yaml
listen_address: :8080
listen_address: 127.0.0.1:8080
remote_address: ""
ports:
- "tcp://localhost:22"
@@ -355,6 +366,7 @@ ports:
```yaml
listen_address: ""
remote_address: server:8080
bind_address: 127.0.0.1
ports:
- "tcp://22:2222"
```
@@ -369,6 +381,7 @@ ports:
- Encryption keys are validated for entropy and strength
- Logging includes automatic sanitization of sensitive data
- Rate limiting prevents abuse and DoS attacks
- Client local listeners and the built-in DNS server bind `127.0.0.1` by default so LAN/internet hosts cannot use your tunnel unless you set `bind_address` / a per-rule host to `0.0.0.0`
## Example Use Cases
@@ -394,7 +407,7 @@ ports:
- Generate: `./teleport -generate-config -config teleport-client.yaml`
- Edit the configuration file with local ports and remote server address
- Run: `./teleport -config teleport-client.yaml`
- Connect to `localhost:local_port` to access the remote service
- Connect to `127.0.0.1:local_port` (loopback by default) to access the remote service
## Multi-Client Support
@@ -428,6 +441,7 @@ The built-in DNS server provides:
dns_server:
enabled: true
listen_port: 5353
bind_address: 127.0.0.1
backup_server: 8.8.8.8:53
custom_records:
- name: api.local
@@ -510,8 +524,8 @@ Teleport includes sophisticated logging with:
- The encryption key must be identical on both server and client
- Use `./teleport --generate-key` to create a secure random encryption key
- The server listens on the specified `listen_address` for incoming connections
- The client connects to the remote server and forwards local connections
- The server listens on the specified `listen_address` for incoming tunnel connections (examples bind loopback; `0.0.0.0` must be explicit)
- The client connects to the remote server and forwards local connections from `bind_address` (default `127.0.0.1`)
- All port forwarding is bidirectional
- Port format uses URL-style conventions: `"protocol://target:port"`
- Configuration files use YAML format for better readability
+1 -1
View File
@@ -1,6 +1,6 @@
module teleport
go 1.23.5
go 1.27.0
require (
github.com/miekg/dns v1.1.68
+57 -15
View File
@@ -28,6 +28,7 @@ type TeleportClient struct {
cancel context.CancelFunc
connectionPool chan net.Conn
maxPoolSize int
derivedKey []byte
}
// NewTeleportClient creates a new teleport client
@@ -49,6 +50,7 @@ func NewTeleportClient(config *config.Config) *TeleportClient {
cancel: cancel,
connectionPool: make(chan net.Conn, maxPoolSize),
maxPoolSize: maxPoolSize,
derivedKey: encryption.DeriveKey(config.EncryptionKey),
}
}
@@ -91,9 +93,11 @@ func (tc *TeleportClient) startPortForwarding(rule config.PortRule) {
// startTCPForwarding starts TCP port forwarding
func (tc *TeleportClient) startTCPForwarding(rule config.PortRule) {
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", rule.LocalPort))
listenAddr := tc.config.ClientListenAddr(rule)
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
logger.WithFields(map[string]interface{}{
"addr": listenAddr,
"port": rule.LocalPort,
"error": err,
}).Error("Failed to start TCP listener")
@@ -102,6 +106,7 @@ func (tc *TeleportClient) startTCPForwarding(rule config.PortRule) {
defer listener.Close()
logger.WithFields(map[string]interface{}{
"listen_addr": listenAddr,
"local_port": rule.LocalPort,
"remote_addr": tc.config.RemoteAddress,
"remote_port": rule.RemotePort,
@@ -252,9 +257,11 @@ func (tc *TeleportClient) returnConnection(conn net.Conn) {
// startUDPForwarding starts UDP port forwarding
func (tc *TeleportClient) startUDPForwarding(rule config.PortRule) {
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", rule.LocalPort))
listenAddr := tc.config.ClientListenAddr(rule)
addr, err := net.ResolveUDPAddr("udp", listenAddr)
if err != nil {
logger.WithFields(map[string]interface{}{
"addr": listenAddr,
"port": rule.LocalPort,
"error": err,
}).Error("Failed to resolve UDP address")
@@ -264,6 +271,7 @@ func (tc *TeleportClient) startUDPForwarding(rule config.PortRule) {
conn, err := net.ListenUDP("udp", addr)
if err != nil {
logger.WithFields(map[string]interface{}{
"addr": listenAddr,
"port": rule.LocalPort,
"error": err,
}).Error("Failed to start UDP listener")
@@ -275,6 +283,7 @@ func (tc *TeleportClient) startUDPForwarding(rule config.PortRule) {
tc.udpMutex.Unlock()
logger.WithFields(map[string]interface{}{
"listen_addr": listenAddr,
"local_port": rule.LocalPort,
"remote_addr": tc.config.RemoteAddress,
"remote_port": rule.RemotePort,
@@ -639,7 +648,12 @@ func (tc *TeleportClient) handleTCPConnection(clientConn net.Conn, rule config.P
defer tc.returnConnection(serverConn)
// Send port forward request to server
request := types.PortForwardRequest(rule)
request := types.PortForwardRequest{
LocalPort: rule.LocalPort,
RemotePort: rule.RemotePort,
Protocol: rule.Protocol,
TargetHost: rule.TargetHost,
}
if err := tc.sendRequestToConnection(serverConn, request); err != nil {
logger.WithField("error", err).Error("Failed to send port forward request")
@@ -650,23 +664,34 @@ func (tc *TeleportClient) handleTCPConnection(clientConn net.Conn, rule config.P
var wg sync.WaitGroup
wg.Add(2)
// Forward data from client to server
// Encrypt local plaintext onto the tunnel
go func() {
defer wg.Done()
tc.forwardData(clientConn, serverConn)
tc.forwardData(clientConn, serverConn, true)
}()
// Forward data from server to client
// Decrypt tunnel frames to the local connection
go func() {
defer wg.Done()
tc.forwardData(serverConn, clientConn)
tc.forwardData(serverConn, clientConn, false)
}()
wg.Wait()
}
// forwardData forwards data from src to dst
func (tc *TeleportClient) forwardData(src, dst net.Conn) {
// forwardData copies between a local/plaintext conn and the tunnel.
// When toTunnel is true, plaintext is AES-GCM encrypted and written as
// uint32 length + ciphertext. When false, framed ciphertext is decrypted
// and written as plaintext.
func (tc *TeleportClient) forwardData(src, dst net.Conn, toTunnel bool) {
if toTunnel {
tc.forwardPlainToTunnel(src, dst)
return
}
tc.forwardTunnelToPlain(src, dst)
}
func (tc *TeleportClient) forwardPlainToTunnel(src, dst net.Conn) {
buffer := make([]byte, 4096)
for {
select {
@@ -675,14 +700,32 @@ func (tc *TeleportClient) forwardData(src, dst net.Conn) {
default:
n, err := src.Read(buffer)
if err != nil {
// Close the destination connection when source closes
dst.Close()
return
}
if n == 0 {
continue
}
if err := encryption.WriteEncryptedFrame(dst, buffer[:n], tc.derivedKey); err != nil {
src.Close()
return
}
}
}
}
_, err = dst.Write(buffer[:n])
func (tc *TeleportClient) forwardTunnelToPlain(src, dst net.Conn) {
for {
select {
case <-tc.ctx.Done():
return
default:
plain, err := encryption.ReadEncryptedFrame(src, tc.derivedKey)
if err != nil {
// Close the source connection when destination closes
dst.Close()
return
}
if _, err := dst.Write(plain); err != nil {
src.Close()
return
}
@@ -703,9 +746,8 @@ func (tc *TeleportClient) sendRequestToConnection(conn net.Conn, request types.P
return err
}
// Encrypt the data
key := encryption.DeriveKey(tc.config.EncryptionKey)
encryptedData, err := encryption.EncryptData(data, key)
// Encrypt the data (key derived once at process start)
encryptedData, err := encryption.EncryptData(data, tc.derivedKey)
if err != nil {
logger.WithFields(map[string]interface{}{
"error": err,
+120
View File
@@ -0,0 +1,120 @@
package client
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
"teleport/pkg/config"
"teleport/pkg/encryption"
)
func testClient(t *testing.T) *TeleportClient {
t.Helper()
return NewTeleportClient(&config.Config{
EncryptionKey: "a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03",
})
}
func TestForwardDataHidesHTTPPlaintextOnTunnel(t *testing.T) {
tc := testClient(t)
defer tc.Stop()
localA, localB := net.Pipe()
tunnelA, tunnelB := net.Pipe()
defer localA.Close()
defer localB.Close()
defer tunnelA.Close()
defer tunnelB.Close()
go tc.forwardData(localA, tunnelA, true)
req := []byte("GET /secret HTTP/1.1\r\nHost: example.com\r\n\r\n")
writeErr := make(chan error, 1)
go func() {
_, err := localB.Write(req)
writeErr <- err
}()
tunnelB.SetReadDeadline(time.Now().Add(5 * time.Second))
var length uint32
if err := binary.Read(tunnelB, binary.BigEndian, &length); err != nil {
t.Fatalf("failed to read frame length from tunnel: %v", err)
}
if length == 0 || length > encryption.MaxFrameSize {
t.Fatalf("unexpected frame length %d", length)
}
ciphertext := make([]byte, length)
if _, err := io.ReadFull(tunnelB, ciphertext); err != nil {
t.Fatalf("failed to read ciphertext from tunnel: %v", err)
}
if bytes.Contains(ciphertext, []byte("GET /")) ||
bytes.Contains(ciphertext, []byte("Host:")) ||
bytes.Contains(ciphertext, []byte("example.com")) {
t.Fatal("HTTP plaintext visible on client->server tunnel")
}
var frame bytes.Buffer
if err := binary.Write(&frame, binary.BigEndian, length); err != nil {
t.Fatal(err)
}
frame.Write(ciphertext)
plain, err := encryption.ReadEncryptedFrame(&frame, tc.derivedKey)
if err != nil {
t.Fatalf("decrypt of tunneled frame failed: %v", err)
}
if !bytes.Equal(plain, req) {
t.Errorf("decrypted payload mismatch: got %q want %q", plain, req)
}
select {
case err := <-writeErr:
if err != nil {
t.Fatalf("write to local conn failed: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for local write")
}
}
func TestForwardDataDecryptsTunnelToLocal(t *testing.T) {
tc := testClient(t)
defer tc.Stop()
tunnelA, tunnelB := net.Pipe()
localA, localB := net.Pipe()
defer tunnelA.Close()
defer tunnelB.Close()
defer localA.Close()
defer localB.Close()
go tc.forwardData(tunnelA, localA, false)
payload := []byte("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
writeErr := make(chan error, 1)
go func() {
writeErr <- encryption.WriteEncryptedFrame(tunnelB, payload, tc.derivedKey)
}()
localB.SetReadDeadline(time.Now().Add(5 * time.Second))
got := make([]byte, len(payload))
if _, err := io.ReadFull(localB, got); err != nil {
t.Fatalf("failed to read decrypted payload: %v", err)
}
if !bytes.Equal(got, payload) {
t.Errorf("got %q want %q", got, payload)
}
select {
case err := <-writeErr:
if err != nil {
t.Fatalf("WriteEncryptedFrame failed: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for tunnel write")
}
}
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
"teleport/pkg/config"
"teleport/pkg/encryption"
)
func testServer(t *testing.T) *TeleportServer {
t.Helper()
return NewTeleportServer(&config.Config{
EncryptionKey: "a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03",
})
}
func TestForwardDataHidesHTTPPlaintextOnTunnel(t *testing.T) {
ts := testServer(t)
defer ts.Stop()
targetA, targetB := net.Pipe()
tunnelA, tunnelB := net.Pipe()
defer targetA.Close()
defer targetB.Close()
defer tunnelA.Close()
defer tunnelB.Close()
// target -> tunnel (encrypt)
go ts.forwardData(targetA, tunnelA, true)
resp := []byte("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello")
writeErr := make(chan error, 1)
go func() {
_, err := targetB.Write(resp)
writeErr <- err
}()
tunnelB.SetReadDeadline(time.Now().Add(5 * time.Second))
var length uint32
if err := binary.Read(tunnelB, binary.BigEndian, &length); err != nil {
t.Fatalf("failed to read frame length from tunnel: %v", err)
}
ciphertext := make([]byte, length)
if _, err := io.ReadFull(tunnelB, ciphertext); err != nil {
t.Fatalf("failed to read ciphertext from tunnel: %v", err)
}
if bytes.Contains(ciphertext, []byte("HTTP/1.1")) ||
bytes.Contains(ciphertext, []byte("Content-Type")) ||
bytes.Contains(ciphertext, []byte("hello")) {
t.Fatal("HTTP plaintext visible on server->client tunnel")
}
var frame bytes.Buffer
if err := binary.Write(&frame, binary.BigEndian, length); err != nil {
t.Fatal(err)
}
frame.Write(ciphertext)
plain, err := encryption.ReadEncryptedFrame(&frame, ts.derivedKey)
if err != nil {
t.Fatalf("decrypt of tunneled frame failed: %v", err)
}
if !bytes.Equal(plain, resp) {
t.Errorf("decrypted payload mismatch: got %q want %q", plain, resp)
}
select {
case err := <-writeErr:
if err != nil {
t.Fatalf("write to target conn failed: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for target write")
}
}
func TestForwardDataDecryptsTunnelToTarget(t *testing.T) {
ts := testServer(t)
defer ts.Stop()
tunnelA, tunnelB := net.Pipe()
targetA, targetB := net.Pipe()
defer tunnelA.Close()
defer tunnelB.Close()
defer targetA.Close()
defer targetB.Close()
go ts.forwardData(tunnelA, targetA, false)
req := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
writeErr := make(chan error, 1)
go func() {
writeErr <- encryption.WriteEncryptedFrame(tunnelB, req, ts.derivedKey)
}()
targetB.SetReadDeadline(time.Now().Add(5 * time.Second))
got := make([]byte, len(req))
if _, err := io.ReadFull(targetB, got); err != nil {
t.Fatalf("failed to read decrypted payload: %v", err)
}
if !bytes.Equal(got, req) {
t.Errorf("got %q want %q", got, req)
}
select {
case err := <-writeErr:
if err != nil {
t.Fatalf("WriteEncryptedFrame failed: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for tunnel write")
}
}
+40 -10
View File
@@ -35,6 +35,7 @@ type TeleportServer struct {
goroutineSem chan struct{} // Semaphore for limiting concurrent goroutines
maxGoroutines int // Maximum concurrent goroutines
metrics *metrics.Metrics
derivedKey []byte
}
// NewTeleportServer creates a new teleport server
@@ -76,6 +77,7 @@ func NewTeleportServer(config *config.Config) *TeleportServer {
goroutineSem: make(chan struct{}, maxGoroutines),
maxGoroutines: maxGoroutines,
metrics: metricsInstance,
derivedKey: encryption.DeriveKey(config.EncryptionKey),
}
}
@@ -403,12 +405,12 @@ func (ts *TeleportServer) handleTCPForward(clientConn net.Conn, rule *config.Por
go func() {
defer wg.Done()
ts.forwardData(clientConn, targetConn)
ts.forwardData(clientConn, targetConn, false) // decrypt from tunnel
}()
go func() {
defer wg.Done()
ts.forwardData(targetConn, clientConn)
ts.forwardData(targetConn, clientConn, true) // encrypt onto tunnel
}()
wg.Wait()
@@ -704,8 +706,19 @@ func (ts *TeleportServer) deserializeTaggedUDPPacket(data []byte) (types.TaggedU
}, nil
}
// forwardData forwards data between two connections
func (ts *TeleportServer) forwardData(src, dst net.Conn) {
// forwardData copies between a local/target conn and the tunnel.
// When toTunnel is true, plaintext is AES-GCM encrypted and written as
// uint32 length + ciphertext. When false, framed ciphertext is decrypted
// and written as plaintext.
func (ts *TeleportServer) forwardData(src, dst net.Conn, toTunnel bool) {
if toTunnel {
ts.forwardPlainToTunnel(src, dst)
return
}
ts.forwardTunnelToPlain(src, dst)
}
func (ts *TeleportServer) forwardPlainToTunnel(src, dst net.Conn) {
buffer := make([]byte, 4096)
for {
select {
@@ -714,14 +727,32 @@ func (ts *TeleportServer) forwardData(src, dst net.Conn) {
default:
n, err := src.Read(buffer)
if err != nil {
// Close the destination connection when source closes
dst.Close()
return
}
if n == 0 {
continue
}
if err := encryption.WriteEncryptedFrame(dst, buffer[:n], ts.derivedKey); err != nil {
src.Close()
return
}
}
}
}
_, err = dst.Write(buffer[:n])
func (ts *TeleportServer) forwardTunnelToPlain(src, dst net.Conn) {
for {
select {
case <-ts.ctx.Done():
return
default:
plain, err := encryption.ReadEncryptedFrame(src, ts.derivedKey)
if err != nil {
// Close the source connection when destination closes
dst.Close()
return
}
if _, err := dst.Write(plain); err != nil {
src.Close()
return
}
@@ -757,9 +788,8 @@ func (ts *TeleportServer) readRequest(conn net.Conn, request *types.PortForwardR
bytesRead += n
}
// Decrypt the data
key := encryption.DeriveKey(ts.config.EncryptionKey)
decryptedData, err := encryption.DecryptData(encryptedData, key)
// Decrypt the data (key derived once at process start)
decryptedData, err := encryption.DecryptData(encryptedData, ts.derivedKey)
if err != nil {
logger.WithFields(map[string]interface{}{
"error": err,
+103 -8
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"os"
"strconv"
"strings"
@@ -19,6 +20,7 @@ type Config struct {
InstanceID string `yaml:"instance_id"`
ListenAddress string `yaml:"listen_address"`
RemoteAddress string `yaml:"remote_address"`
BindAddress string `yaml:"bind_address"` // local TCP/UDP/DNS bind host; default 127.0.0.1
Ports []PortRule `yaml:"ports"`
EncryptionKey string `yaml:"encryption_key"`
KeepAlive bool `yaml:"keep_alive"`
@@ -35,6 +37,39 @@ type PortRule struct {
RemotePort int `yaml:"remote_port"`
Protocol string `yaml:"protocol"` // "tcp" or "udp"
TargetHost string `yaml:"target_host,omitempty"` // Target host for server-side forwarding (defaults to localhost)
BindAddress string `yaml:"-"` // Client local listen host; empty means use Config.BindAddress
}
// DefaultBindAddress is the loopback host used when bind_address is omitted.
// Binding 0.0.0.0 (all interfaces) requires an explicit override.
const DefaultBindAddress = "127.0.0.1"
// NormalizeBindAddress returns host, or 127.0.0.1 when host is empty.
func NormalizeBindAddress(host string) string {
if host == "" {
return DefaultBindAddress
}
return host
}
// LocalListenAddr returns host:port for a local listener. Empty host becomes 127.0.0.1.
func LocalListenAddr(host string, port int) string {
return net.JoinHostPort(NormalizeBindAddress(host), strconv.Itoa(port))
}
// ClientListenAddr is the address the client binds for a port rule.
// Per-rule BindAddress wins over Config.BindAddress; both default to 127.0.0.1.
func (c *Config) ClientListenAddr(rule PortRule) string {
host := rule.BindAddress
if host == "" && c != nil {
host = c.BindAddress
}
return LocalListenAddr(host, rule.LocalPort)
}
// ListenAddr is the address the built-in DNS server binds. Default 127.0.0.1.
func (d DNSServerConfig) ListenAddr() string {
return LocalListenAddr(d.BindAddress, d.ListenPort)
}
// UnmarshalYAML implements custom YAML unmarshaling for PortRule
@@ -133,21 +168,70 @@ func (p *PortRule) UnmarshalYAML(value *yaml.Node) error {
p.TargetHost = "" // Client doesn't specify target host
return nil
}
} else if len(addressParts) >= 3 {
// Client format with bind host: protocol://targetport:bindhost:localport
// e.g. tcp://22:127.0.0.1:2222 or tcp://22:0.0.0.0:2222
firstColon := strings.Index(addressPart, ":")
lastColon := strings.LastIndex(addressPart, ":")
if firstColon < 0 || lastColon <= firstColon {
return fmt.Errorf("invalid address format: %s (expected 'targetport:bindhost:localport')", addressPart)
}
targetPortStr := addressPart[:firstColon]
bindHost := addressPart[firstColon+1 : lastColon]
localPortStr := addressPart[lastColon+1:]
bindHost = strings.TrimPrefix(bindHost, "[")
bindHost = strings.TrimSuffix(bindHost, "]")
if bindHost == "" {
return fmt.Errorf("bind host is required in format 'targetport:bindhost:localport'")
}
if len(bindHost) > 253 {
return fmt.Errorf("bind host too long")
}
for _, c := range bindHost {
if c < 32 || c > 126 {
return fmt.Errorf("invalid character in bind host")
}
}
targetPort, err := strconv.Atoi(targetPortStr)
if err != nil {
return fmt.Errorf("invalid target port: %s", targetPortStr)
}
localPort, err := strconv.Atoi(localPortStr)
if err != nil {
return fmt.Errorf("invalid local port: %s", localPortStr)
}
if targetPort < 1 || targetPort > 65535 {
return fmt.Errorf("invalid target port: %d (must be 1-65535)", targetPort)
}
if localPort < 1 || localPort > 65535 {
return fmt.Errorf("invalid local port: %d (must be 1-65535)", localPort)
}
p.LocalPort = localPort
p.RemotePort = targetPort
p.Protocol = protocol
p.TargetHost = ""
p.BindAddress = bindHost
return nil
} else {
return fmt.Errorf("invalid address format: %s (expected 'target:port' for server or 'targetport:localport' for client)", addressPart)
return fmt.Errorf("invalid address format: %s (expected 'target:port' for server, 'targetport:localport' for client, or 'targetport:bindhost:localport' for client with bind)", addressPart)
}
}
// MarshalYAML implements custom YAML marshaling for PortRule
func (p PortRule) MarshalYAML() (interface{}, error) {
// Use new format: protocol://target:targetport (server) or protocol://targetport:localport (client)
// Server: protocol://target:targetport
// Client: protocol://targetport:localport
// Client with explicit bind: protocol://targetport:bindhost:localport
if p.TargetHost == "" {
// Client format: protocol://targetport:localport
return fmt.Sprintf("%s://%d:%d", p.Protocol, p.RemotePort, p.LocalPort), nil
} else {
// Server format: protocol://target:targetport
return fmt.Sprintf("%s://%s:%d", p.Protocol, p.TargetHost, p.RemotePort), nil
if p.BindAddress != "" {
return fmt.Sprintf("%s://%d:%s:%d", p.Protocol, p.RemotePort, p.BindAddress, p.LocalPort), nil
}
return fmt.Sprintf("%s://%d:%d", p.Protocol, p.RemotePort, p.LocalPort), nil
}
return fmt.Sprintf("%s://%s:%d", p.Protocol, p.TargetHost, p.RemotePort), nil
}
// RateLimitConfig defines rate limiting configuration
@@ -162,6 +246,7 @@ type RateLimitConfig struct {
type DNSServerConfig struct {
Enabled bool `yaml:"enabled"`
ListenPort int `yaml:"listen_port"`
BindAddress string `yaml:"bind_address"`
BackupServer string `yaml:"backup_server"`
CustomRecords []DNSRecord `yaml:"custom_records"`
}
@@ -217,9 +302,15 @@ func LoadConfig(filename string) (*Config, error) {
if config.RateLimit.WindowSize == 0 {
config.RateLimit.WindowSize = 1 * time.Second
}
if config.BindAddress == "" {
config.BindAddress = DefaultBindAddress
}
if config.DNSServer.ListenPort == 0 {
config.DNSServer.ListenPort = 5353
}
if config.DNSServer.BindAddress == "" {
config.DNSServer.BindAddress = DefaultBindAddress
}
if config.DNSServer.BackupServer == "" {
config.DNSServer.BackupServer = "8.8.8.8:53"
}
@@ -457,8 +548,9 @@ func GenerateExampleConfig(filename string) error {
// Generate server configuration
config = Config{
InstanceID: "teleport-server-01",
ListenAddress: ":8080",
ListenAddress: "127.0.0.1:8080",
RemoteAddress: "",
BindAddress: DefaultBindAddress,
Ports: []PortRule{
{LocalPort: 80, RemotePort: 80, Protocol: "tcp", TargetHost: "localhost"},
},
@@ -475,6 +567,7 @@ func GenerateExampleConfig(filename string) error {
},
DNSServer: DNSServerConfig{
ListenPort: 5353,
BindAddress: DefaultBindAddress,
BackupServer: "8.8.8.8:53",
CustomRecords: []DNSRecord{},
},
@@ -485,6 +578,7 @@ func GenerateExampleConfig(filename string) error {
InstanceID: "teleport-client-01",
ListenAddress: "",
RemoteAddress: "localhost:8080",
BindAddress: DefaultBindAddress,
Ports: []PortRule{
{LocalPort: 8080, RemotePort: 80, Protocol: "tcp", TargetHost: ""},
},
@@ -501,6 +595,7 @@ func GenerateExampleConfig(filename string) error {
},
DNSServer: DNSServerConfig{
ListenPort: 5353,
BindAddress: DefaultBindAddress,
BackupServer: "8.8.8.8:53",
CustomRecords: []DNSRecord{
{Name: "app.local", Type: "A", Value: "127.0.0.1", TTL: 300},
+224
View File
@@ -1,6 +1,7 @@
package config
import (
"net"
"os"
"path/filepath"
"strings"
@@ -135,6 +136,14 @@ encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
if config.DNSServer.BackupServer != "8.8.8.8:53" {
t.Errorf("Expected default backup server '8.8.8.8:53', got '%s'", config.DNSServer.BackupServer)
}
if config.BindAddress != DefaultBindAddress {
t.Errorf("Expected default BindAddress %q, got %q", DefaultBindAddress, config.BindAddress)
}
if config.DNSServer.BindAddress != DefaultBindAddress {
t.Errorf("Expected default DNS BindAddress %q, got %q", DefaultBindAddress, config.DNSServer.BindAddress)
}
}
func TestDetectMode(t *testing.T) {
@@ -361,3 +370,218 @@ encryption_key: test-key
t.Errorf("Expected error message to mention 'protocol://', got: %v", err)
}
}
func TestDefaultLocalListenAddrIsLoopback(t *testing.T) {
if got := LocalListenAddr("", 9000); got != "127.0.0.1:9000" {
t.Fatalf("empty host: got %q want 127.0.0.1:9000", got)
}
if got := LocalListenAddr("127.0.0.1", 2222); got != "127.0.0.1:2222" {
t.Fatalf("loopback host: got %q", got)
}
cfg := &Config{}
tcpRule := PortRule{LocalPort: 8080, RemotePort: 80, Protocol: "tcp"}
udpRule := PortRule{LocalPort: 5353, RemotePort: 53, Protocol: "udp"}
if got := cfg.ClientListenAddr(tcpRule); got != "127.0.0.1:8080" {
t.Fatalf("default TCP listen: got %q want 127.0.0.1:8080", got)
}
if got := cfg.ClientListenAddr(udpRule); got != "127.0.0.1:5353" {
t.Fatalf("default UDP listen: got %q want 127.0.0.1:5353", got)
}
dnsCfg := DNSServerConfig{ListenPort: 5353}
if got := dnsCfg.ListenAddr(); got != "127.0.0.1:5353" {
t.Fatalf("default DNS listen: got %q want 127.0.0.1:5353", got)
}
}
func TestExplicitBindAddressOverride(t *testing.T) {
if got := LocalListenAddr("0.0.0.0", 9000); got != "0.0.0.0:9000" {
t.Fatalf("0.0.0.0 override: got %q", got)
}
cfg := &Config{BindAddress: "0.0.0.0"}
rule := PortRule{LocalPort: 2222, RemotePort: 22, Protocol: "tcp"}
if got := cfg.ClientListenAddr(rule); got != "0.0.0.0:2222" {
t.Fatalf("global bind_address 0.0.0.0: got %q", got)
}
rule.BindAddress = "10.0.0.5"
if got := cfg.ClientListenAddr(rule); got != "10.0.0.5:2222" {
t.Fatalf("per-rule host should win: got %q", got)
}
dnsCfg := DNSServerConfig{ListenPort: 5353, BindAddress: "0.0.0.0"}
if got := dnsCfg.ListenAddr(); got != "0.0.0.0:5353" {
t.Fatalf("DNS bind_address 0.0.0.0: got %q", got)
}
}
func TestLocalListenAddrBinds(t *testing.T) {
ln, err := net.Listen("tcp", LocalListenAddr("", 0))
if err != nil {
t.Fatalf("listen default: %v", err)
}
defer ln.Close()
ip := ln.Addr().(*net.TCPAddr).IP
if !ip.IsLoopback() {
t.Fatalf("default TCP bind is not loopback: %v", ip)
}
udpAddr, err := net.ResolveUDPAddr("udp", LocalListenAddr("", 0))
if err != nil {
t.Fatalf("resolve default udp: %v", err)
}
uc, err := net.ListenUDP("udp", udpAddr)
if err != nil {
t.Fatalf("listen default udp: %v", err)
}
defer uc.Close()
if !uc.LocalAddr().(*net.UDPAddr).IP.IsLoopback() {
t.Fatalf("default UDP bind is not loopback: %v", uc.LocalAddr())
}
all, err := net.Listen("tcp", LocalListenAddr("0.0.0.0", 0))
if err != nil {
t.Fatalf("listen 0.0.0.0: %v", err)
}
defer all.Close()
if !all.Addr().(*net.TCPAddr).IP.IsUnspecified() {
t.Fatalf("explicit 0.0.0.0 bind is not unspecified: %v", all.Addr())
}
}
func TestPortRuleBindHostFormat(t *testing.T) {
tests := []struct {
name string
config string
expected PortRule
}{
{
name: "client with loopback bind",
config: `
instance_id: test
remote_address: localhost:8080
ports:
- tcp://22:127.0.0.1:2222
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
`,
expected: PortRule{LocalPort: 2222, RemotePort: 22, Protocol: "tcp", BindAddress: "127.0.0.1"},
},
{
name: "client with all-interfaces bind",
config: `
instance_id: test
remote_address: localhost:8080
ports:
- tcp://22:0.0.0.0:2222
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
`,
expected: PortRule{LocalPort: 2222, RemotePort: 22, Protocol: "tcp", BindAddress: "0.0.0.0"},
},
{
name: "client two-part still defaults bind via config",
config: `
instance_id: test
remote_address: localhost:8080
ports:
- tcp://80:8080
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
`,
expected: PortRule{LocalPort: 8080, RemotePort: 80, Protocol: "tcp", BindAddress: ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "test-config.yaml")
if err := os.WriteFile(configFile, []byte(tt.config), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := LoadConfig(configFile)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cfg.Ports) != 1 {
t.Fatalf("expected 1 port, got %d", len(cfg.Ports))
}
port := cfg.Ports[0]
if port.LocalPort != tt.expected.LocalPort || port.RemotePort != tt.expected.RemotePort ||
port.Protocol != tt.expected.Protocol || port.BindAddress != tt.expected.BindAddress {
t.Errorf("got %+v want %+v", port, tt.expected)
}
})
}
}
func TestLoadConfigGlobalBindAddress(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "cfg.yaml")
content := `
instance_id: test
remote_address: localhost:8080
bind_address: 0.0.0.0
ports:
- tcp://80:8080
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
dns_server:
enabled: true
listen_port: 5353
bind_address: 0.0.0.0
backup_server: 8.8.8.8:53
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(configFile)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.BindAddress != "0.0.0.0" {
t.Fatalf("BindAddress got %q", cfg.BindAddress)
}
if cfg.DNSServer.BindAddress != "0.0.0.0" {
t.Fatalf("DNS BindAddress got %q", cfg.DNSServer.BindAddress)
}
if got := cfg.ClientListenAddr(cfg.Ports[0]); got != "0.0.0.0:8080" {
t.Fatalf("client listen got %q", got)
}
if got := cfg.DNSServer.ListenAddr(); got != "0.0.0.0:5353" {
t.Fatalf("dns listen got %q", got)
}
}
func TestGenerateExampleConfigUsesLoopback(t *testing.T) {
tempDir := t.TempDir()
serverFile := filepath.Join(tempDir, "server.yaml")
if err := GenerateExampleConfig(serverFile); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(serverFile)
if err != nil {
t.Fatal(err)
}
body := string(data)
if strings.Contains(body, "listen_address: :8080") || strings.Contains(body, "listen_address: :9000") {
t.Fatalf("generated server config still documents all-interfaces listen: %s", body)
}
if !strings.Contains(body, "127.0.0.1:8080") {
t.Fatalf("generated server config missing loopback listen_address: %s", body)
}
clientFile := filepath.Join(tempDir, "client.yaml")
if err := GenerateExampleConfig(clientFile); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(clientFile)
if err != nil {
t.Fatalf("reload generated client: %v", err)
}
if cfg.BindAddress != DefaultBindAddress {
t.Fatalf("generated client bind_address %q", cfg.BindAddress)
}
if got := cfg.ClientListenAddr(cfg.Ports[0]); !strings.HasPrefix(got, "127.0.0.1:") {
t.Fatalf("generated client local listen %q", got)
}
}
+6 -3
View File
@@ -1,7 +1,6 @@
package dns
import (
"fmt"
"net"
"strings"
"time"
@@ -18,9 +17,10 @@ func StartDNSServer(cfg *config.Config) {
return
}
listenAddr := cfg.DNSServer.ListenAddr()
// Create DNS server
server := &dns.Server{
Addr: fmt.Sprintf(":%d", cfg.DNSServer.ListenPort),
Addr: listenAddr,
Net: "udp",
}
@@ -29,7 +29,10 @@ func StartDNSServer(cfg *config.Config) {
handleDNSQuery(w, r, cfg)
})
logger.WithField("port", cfg.DNSServer.ListenPort).Info("DNS server started")
logger.WithFields(map[string]interface{}{
"addr": listenAddr,
"port": cfg.DNSServer.ListenPort,
}).Info("DNS server started")
// Start server
if err := server.ListenAndServe(); err != nil {
+68
View File
@@ -0,0 +1,68 @@
package encryption
import (
"encoding/binary"
"fmt"
"io"
)
// MaxFrameSize is the maximum AES-GCM ciphertext length accepted on a framed
// TCP tunnel (uint32 length prefix + ciphertext).
const MaxFrameSize = 1024 * 1024 // 1 MiB
// WriteEncryptedFrame encrypts plaintext with AES-GCM (EncryptData) and writes
// a uint32 big-endian length prefix followed by the ciphertext.
func WriteEncryptedFrame(w io.Writer, plaintext, key []byte) error {
ciphertext, err := EncryptData(plaintext, key)
if err != nil {
return err
}
if len(ciphertext) == 0 {
return fmt.Errorf("encrypted data cannot be empty")
}
if len(ciphertext) > MaxFrameSize {
return fmt.Errorf("frame too large: %d bytes (max %d)", len(ciphertext), MaxFrameSize)
}
var length [4]byte
binary.BigEndian.PutUint32(length[:], uint32(len(ciphertext)))
if err := writeFull(w, length[:]); err != nil {
return err
}
return writeFull(w, ciphertext)
}
// ReadEncryptedFrame reads a uint32 big-endian length, caps it, reads the
// ciphertext, and decrypts it with AES-GCM (DecryptData).
func ReadEncryptedFrame(r io.Reader, key []byte) ([]byte, error) {
var length uint32
if err := binary.Read(r, binary.BigEndian, &length); err != nil {
return nil, err
}
if length == 0 {
return nil, fmt.Errorf("frame length cannot be zero")
}
if length > MaxFrameSize {
return nil, fmt.Errorf("frame too large: %d bytes (max %d)", length, MaxFrameSize)
}
ciphertext := make([]byte, length)
if _, err := io.ReadFull(r, ciphertext); err != nil {
return nil, fmt.Errorf("failed to read frame data: %v", err)
}
return DecryptData(ciphertext, key)
}
func writeFull(w io.Writer, data []byte) error {
for len(data) > 0 {
n, err := w.Write(data)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
data = data[n:]
}
return nil
}
+136
View File
@@ -0,0 +1,136 @@
package encryption
import (
"bytes"
"encoding/binary"
"io"
"strings"
"testing"
)
func testKey(t *testing.T) []byte {
t.Helper()
return DeriveKey("test-encryption-key-for-tcp-frames")
}
func TestEncryptedFrameRoundTrip(t *testing.T) {
key := testKey(t)
original := []byte("Hello, framed TCP tunnel!")
var buf bytes.Buffer
if err := WriteEncryptedFrame(&buf, original, key); err != nil {
t.Fatalf("WriteEncryptedFrame failed: %v", err)
}
got, err := ReadEncryptedFrame(&buf, key)
if err != nil {
t.Fatalf("ReadEncryptedFrame failed: %v", err)
}
if !bytes.Equal(got, original) {
t.Errorf("round trip mismatch: got %q want %q", got, original)
}
}
func TestEncryptedFrameMultipleChunks(t *testing.T) {
key := testKey(t)
chunks := [][]byte{
[]byte("chunk-one"),
[]byte("chunk-two-is-longer"),
[]byte{0x00, 0x01, 0xff},
}
var buf bytes.Buffer
for _, c := range chunks {
if err := WriteEncryptedFrame(&buf, c, key); err != nil {
t.Fatalf("WriteEncryptedFrame failed: %v", err)
}
}
for i, want := range chunks {
got, err := ReadEncryptedFrame(&buf, key)
if err != nil {
t.Fatalf("ReadEncryptedFrame chunk %d failed: %v", i, err)
}
if !bytes.Equal(got, want) {
t.Errorf("chunk %d mismatch: got %q want %q", i, got, want)
}
}
}
func TestEncryptedFrameRejectsZeroLength(t *testing.T) {
key := testKey(t)
var buf bytes.Buffer
if err := binary.Write(&buf, binary.BigEndian, uint32(0)); err != nil {
t.Fatal(err)
}
if _, err := ReadEncryptedFrame(&buf, key); err == nil {
t.Fatal("expected error for zero-length frame")
}
}
func TestEncryptedFrameRejectsOversizeLength(t *testing.T) {
key := testKey(t)
var buf bytes.Buffer
if err := binary.Write(&buf, binary.BigEndian, uint32(MaxFrameSize+1)); err != nil {
t.Fatal(err)
}
if _, err := ReadEncryptedFrame(&buf, key); err == nil {
t.Fatal("expected error for oversize frame length")
} else if !strings.Contains(err.Error(), "too large") {
t.Errorf("expected too-large error, got %v", err)
}
}
func TestEncryptedFrameWrongKey(t *testing.T) {
key1 := DeriveKey("tcp-frame-key-1")
key2 := DeriveKey("tcp-frame-key-2")
var buf bytes.Buffer
if err := WriteEncryptedFrame(&buf, []byte("secret"), key1); err != nil {
t.Fatal(err)
}
if _, err := ReadEncryptedFrame(&buf, key2); err == nil {
t.Fatal("decrypting with the wrong key should fail")
}
}
func TestHTTPPlaintextNotVisibleOnFramedTunnel(t *testing.T) {
key := testKey(t)
req := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
var wire bytes.Buffer
if err := WriteEncryptedFrame(&wire, req, key); err != nil {
t.Fatalf("WriteEncryptedFrame failed: %v", err)
}
sniffed := wire.Bytes()
if bytes.Contains(sniffed, []byte("GET /")) {
t.Fatal("sniffer saw HTTP request line on the tunnel")
}
if bytes.Contains(sniffed, []byte("Host:")) {
t.Fatal("sniffer saw HTTP Host header on the tunnel")
}
if bytes.Contains(sniffed, []byte("example.com")) {
t.Fatal("sniffer saw HTTP hostname on the tunnel")
}
// Length prefix is 4 bytes; ciphertext must be longer than plaintext.
if len(sniffed) < 4+len(req) {
t.Fatalf("framed ciphertext too short: %d", len(sniffed))
}
got, err := ReadEncryptedFrame(bytes.NewReader(sniffed), key)
if err != nil {
t.Fatalf("decrypt failed: %v", err)
}
if !bytes.Equal(got, req) {
t.Errorf("decrypted HTTP mismatch: got %q", got)
}
}
func TestReadEncryptedFrameEOF(t *testing.T) {
key := testKey(t)
if _, err := ReadEncryptedFrame(bytes.NewReader(nil), key); err != io.EOF {
t.Errorf("expected io.EOF, got %v", err)
}
}
+26 -17
View File
@@ -27,28 +27,24 @@ type Config struct {
Compress bool `yaml:"compress"` // compress backup files
}
// Init initializes the global logger with the given configuration
func Init(config Config) error {
mu.Lock()
defer mu.Unlock()
Log = logrus.New()
func newLogger(config Config) (*logrus.Logger, error) {
l := logrus.New()
// Set log level
level, err := logrus.ParseLevel(config.Level)
if err != nil {
level = logrus.InfoLevel
}
Log.SetLevel(level)
l.SetLevel(level)
// Set log format with sanitization
switch config.Format {
case "json":
Log.SetFormatter(&SanitizedJSONFormatter{
l.SetFormatter(&SanitizedJSONFormatter{
TimestampFormat: "2006-01-02 15:04:05",
})
default:
Log.SetFormatter(&SanitizedTextFormatter{
l.SetFormatter(&SanitizedTextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05",
})
@@ -59,36 +55,49 @@ func Init(config Config) error {
// Ensure directory exists
dir := filepath.Dir(config.File)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
return nil, err
}
// Open log file with secure permissions (owner read/write only)
file, err := os.OpenFile(config.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return err
return nil, err
}
// Set output to both file and stdout
Log.SetOutput(io.MultiWriter(file, os.Stdout))
l.SetOutput(io.MultiWriter(file, os.Stdout))
} else {
Log.SetOutput(os.Stdout)
l.SetOutput(os.Stdout)
}
return l, nil
}
// Init initializes the global logger with the given configuration
func Init(config Config) error {
l, err := newLogger(config)
if err != nil {
return err
}
mu.Lock()
Log = l
mu.Unlock()
return nil
}
// GetLogger returns the global logger instance
func GetLogger() *logrus.Logger {
mu.RLock()
if Log != nil {
l := Log
mu.RUnlock()
return Log
if l != nil {
return l
}
mu.RUnlock()
// Initialize with default config if not already initialized
once.Do(func() {
Init(Config{
_ = Init(Config{
Level: "info",
Format: "text",
File: "",