Back to Portmaster

proxy

service/splittun/proxy/README.md

2.2.09.7 KB
Original Source

proxy

Internal Layer-4 TCP and UDP proxy package used by the split-tunnelling subsystem. Provides injected routing decisions, session tracking, and graceful shutdown.


Overview

FeatureTCPUDP
Routing via DeciderFunc
Optional source-address binding
Interface binding via SO_BINDTODEVICE (Linux)
Session tracking + metrics
Pooled copy buffers
Graceful shutdown
Max sessions limit
Read/write deadlines
Idle eviction (cleanup loop)
Bidirectional, half-closen/a

API

Types

go
// LocalBinding carries the local-side binding parameters for an outbound proxy
// connection.  Both fields are optional and may be set independently.
type LocalBinding struct {
    // IP is the local source address to bind the outgoing socket to.
    // If nil, the OS selects an appropriate source address.
    IP net.IP

    // Interface is the name of the network interface (e.g. "eth0") to bind
    // the outgoing socket to via SO_BINDTODEVICE (Linux only).
    // An empty string disables interface-level binding.
    Interface string
}

// DeciderFunc is called once per new session to determine the upstream
// destination and optional local binding parameters for the outgoing socket.
//
// local is the proxy's listen address; peer is the connecting client's address.
//
// It returns:
//   - remoteIP:   required upstream IP address.
//   - remotePort: required upstream port.
//   - binding:    optional local binding; nil lets the OS choose freely.
//                 Set binding.IP to pin the source address, binding.Interface
//                 to restrict the socket to a specific network device (Linux).
//   - extraInfo:  optional caller-defined value attached to the session's ConnContext.
//   - err:        non-nil rejects the session without dialling upstream.
type DeciderFunc func(local net.Addr, peer net.Addr) (
    remoteIP   net.IP,
    remotePort uint16,
    binding    *LocalBinding,
    extraInfo  any,
    err        error,
)

// Logger is the minimal interface accepted by both proxies.
// Pass nil to suppress all log output.
type Logger interface {
    Debug(msg string, args ...any)
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
    Error(msg string, args ...any)
}

// ConnContext holds observable state for one proxy session.
// Counters are updated atomically and safe for concurrent reads.
type ConnContext struct {
    BytesIn    atomic.Uint64 // bytes forwarded upstream → client
    BytesOut   atomic.Uint64 // bytes forwarded client → upstream
    PacketsIn  atomic.Uint64 // UDP datagrams upstream → client
    PacketsOut atomic.Uint64 // UDP datagrams client → upstream
}

func (c *ConnContext) ID()        uint64
func (c *ConnContext) PeerAddr()  net.Addr
func (c *ConnContext) DestIP()    net.IP
func (c *ConnContext) DestPort()  uint16
func (c *ConnContext) CreatedAt() time.Time
func (c *ConnContext) LastSeen()  time.Time
func (c *ConnContext) ExtraInfo() any
func (c *ConnContext) Close()            // cancels the session

Constructors

go
// TCP — uses DefaultConfig
func NewTCPProxy(listenAddr string, network string, decider DeciderFunc, logger Logger, logPrefix string) (*TCPProxy, error)

// TCP — custom configuration
func NewTCPProxyWithConfig(listenAddr string, network string, decider DeciderFunc, logger Logger, cfg Config, logPrefix string) (*TCPProxy, error)

// UDP — uses DefaultConfig
func NewUDPProxy(listenAddr string, network string, decider DeciderFunc, logger Logger, logPrefix string) (*UDPProxy, error)

// UDP — custom configuration
func NewUDPProxyWithConfig(listenAddr string, network string, decider DeciderFunc, logger Logger, cfg Config, logPrefix string) (*UDPProxy, error)

Both constructors bind the socket and start background goroutines immediately. They return an error if binding fails or if decider is nil.

Address

go
func (p *TCPProxy) Addr() net.Addr
func (p *UDPProxy) Addr() net.Addr

Returns the address the proxy is currently listening on.

Configuration

go
type Config struct {
    // MaxSessions is the maximum number of concurrent sessions (0 = unlimited).
    // Default: 2048.
    MaxSessions int

    // ReadTimeout closes a session after this duration with no bytes received
    // from the source.  The deadline is rolled forward on every successful
    // read, so only truly silent sessions are evicted.
    // Default: 5 min.
    ReadTimeout time.Duration

    // WriteTimeout is the maximum time allowed for a single write to complete.
    // Guards against a stalled destination holding a goroutine open.
    // Default: 30 s.
    WriteTimeout time.Duration

    // BufferSize is the size of copy buffers used by the TCP pipe (bytes).
    // UDP always uses 64 KiB buffers regardless of this setting.
    // Default: 32 KiB.
    BufferSize int

    // DialTimeout is the maximum time the TCP proxy waits when dialling the
    // upstream destination.  Default: 10 s.
    DialTimeout time.Duration
}

func DefaultConfig() Config

Shutdown

go
func (p *TCPProxy) Shutdown(ctx context.Context) error
func (p *UDPProxy) Shutdown(ctx context.Context) error

Closes the listen socket, cancels all active sessions, and waits for goroutines to drain. If ctx expires first, the method returns ctx.Err() but goroutines are still cleaning up (they are not leaked).

Session lookup

go
// Returns all active sessions whose upstream destination matches destIP:destPort.
// Returns nil if none exist.
func (p *TCPProxy) FindProxiedEgressConnection(destIP net.IP, destPort uint16) []*ConnContext
func (p *UDPProxy) FindProxiedEgressConnection(destIP net.IP, destPort uint16) []*ConnContext

Metrics

go
type Metrics struct {
    ActiveSessions uint64
    TotalCreated   uint64
    TotalClosed    uint64
}

func (p *TCPProxy) Metrics() Metrics
func (p *UDPProxy) Metrics() Metrics

Usage examples

Transparent TCP proxy (always route to a fixed backend)

go
decider := func(local, peer net.Addr) (net.IP, uint16, *proxy.LocalBinding, any, error) {
    return net.ParseIP("192.168.1.10"), 8080, nil, nil, nil
}

p, err := proxy.NewTCPProxy(":8080", "tcp4", decider, nil, "tcp proxy IPv4")
if err != nil {
    log.Fatal(err)
}

// Graceful shutdown on SIGTERM with a 10-second drain window.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM)
<-sig

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p.Shutdown(ctx)

Per-client routing with source-address and interface binding (split tunnelling)

go
decider := func(local, peer net.Addr) (net.IP, uint16, *proxy.LocalBinding, any, error) {
    host, _, _ := net.SplitHostPort(peer.String())
    if isTunnelledIP(host) {
        // Route through the physical interface, binding the source address and
        // restricting the socket to that device so traffic bypasses the VPN.
        return directGatewayIP, 443, &proxy.LocalBinding{
            IP:        net.ParseIP("192.168.1.5"), // physical interface address
            Interface: "eth0",                     // Linux: SO_BINDTODEVICE
        }, nil, nil
    }
    return vpnGatewayIP, 443, nil, nil, nil
}

p, err := proxy.NewTCPProxy(":443", "tcp4", decider, myLogger, "tcp proxy IPv4")

UDP proxy with custom timeouts

go
cfg := proxy.DefaultConfig()
cfg.ReadTimeout = 30 * time.Second
cfg.MaxSessions = 1024

p, err := proxy.NewUDPProxyWithConfig(":5353", "udp4", decider, myLogger, cfg, "udp proxy IPv4")

Running tests and benchmarks

sh
# Unit tests
go test ./...

# Race detector
go test -race ./...

# Benchmarks with allocation reporting
go test -run='^$' -bench=Benchmark -benchmem    # All benchmarks
go test -run='^$' -bench=BenchmarkTCP -benchmem # TCP only
go test -run='^$' -bench=BenchmarkUDP -benchmem # UDP only

Design notes

  • Pooled buffers — TCP pipes use a sync.Pool of 32 KiB []byte slices; the UDP path uses a separate pool of 64 KiB slices (maximum UDP payload). Both avoid per-transfer heap allocations in steady state.
  • Goroutine budget — the TCP proxy spawns four goroutines per session: one session handler, two bidirectional copy goroutines (one per direction), and one watchdog; the UDP proxy spawns one goroutine per session (upstream reader) plus two shared goroutines (inbound read loop and idle cleanup loop). All goroutines are tracked via a sync.WaitGroup.
  • Half-close — when one TCP peer closes its write side, the proxy attempts CloseWrite on the upstream, enabling proper FIN propagation.
  • NAT session table — UDP sessions are keyed by the client's "ip:port" string. A double-checked locking pattern prevents duplicate sessions under burst traffic.
  • UDP write deadline sharing — all upstream-to-client goroutines write on the same shared listen socket. Each goroutine sets a rolling write deadline immediately before its own write, so concurrent sessions can shift each other's deadline by at most WriteTimeout. This is an accepted trade-off of the single-socket UDP design.
  • Context propagation — the proxy's top-level context.Context is the parent of every session context, so a single Shutdown call cascades to all live sessions.
  • Interface binding (Linux) — when LocalBinding.Interface is non-empty, SO_BINDTODEVICE is set on the outgoing socket via net.Dialer.Control before connect(2). This forces the kernel to route the connection through the named device regardless of the routing table, which is required for split-tunnelling when a default VPN route would otherwise capture the traffic. On non-Linux platforms the field is ignored (no-op).