service/splittun/proxy/README.md
Internal Layer-4 TCP and UDP proxy package used by the split-tunnelling subsystem. Provides injected routing decisions, session tracking, and graceful shutdown.
| Feature | TCP | UDP |
|---|---|---|
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-close | ✓ | n/a |
// 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
// 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.
func (p *TCPProxy) Addr() net.Addr
func (p *UDPProxy) Addr() net.Addr
Returns the address the proxy is currently listening on.
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
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).
// 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
type Metrics struct {
ActiveSessions uint64
TotalCreated uint64
TotalClosed uint64
}
func (p *TCPProxy) Metrics() Metrics
func (p *UDPProxy) Metrics() Metrics
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)
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")
cfg := proxy.DefaultConfig()
cfg.ReadTimeout = 30 * time.Second
cfg.MaxSessions = 1024
p, err := proxy.NewUDPProxyWithConfig(":5353", "udp4", decider, myLogger, cfg, "udp proxy IPv4")
# 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
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.sync.WaitGroup.CloseWrite on the upstream, enabling proper FIN propagation."ip:port"
string. A double-checked locking pattern prevents duplicate sessions under
burst traffic.WriteTimeout. This is an accepted trade-off of
the single-socket UDP design.context.Context is the
parent of every session context, so a single Shutdown call cascades to
all live sessions.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).