diff --git a/service/splittun/proxy/README.md b/service/splittun/proxy/README.md index d99fed2e..47c96013 100644 --- a/service/splittun/proxy/README.md +++ b/service/splittun/proxy/README.md @@ -12,6 +12,7 @@ shutdown. |---------|-----|-----| | Routing via `DeciderFunc` | ✓ | ✓ | | Optional source-address binding | ✓ | ✓ | +| Interface binding via `SO_BINDTODEVICE` (Linux) | ✓ | ✓ | | Session tracking + metrics | ✓ | ✓ | | Pooled copy buffers | ✓ | ✓ | | Graceful shutdown | ✓ | ✓ | @@ -27,15 +28,37 @@ shutdown. ### 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 an optional local IP to bind the outgoing connection to. +// destination and optional local binding parameters for the outgoing socket. +// // local is the proxy's listen address; peer is the connecting client's address. -// Return a non-nil error to reject the session. +// +// 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, - localIP net.IP, // source IP to pin, or nil for OS default - extraInfo any, // optional value attached to the session's ConnContext + binding *LocalBinding, + extraInfo any, err error, ) @@ -55,7 +78,6 @@ type ConnContext struct { 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 @@ -168,7 +190,7 @@ func (p *UDPProxy) Metrics() Metrics ### Transparent TCP proxy (always route to a fixed backend) ```go -decider := func(local, peer net.Addr) (net.IP, uint16, net.IP, any, error) { +decider := func(local, peer net.Addr) (net.IP, uint16, *proxy.LocalBinding, any, error) { return net.ParseIP("192.168.1.10"), 8080, nil, nil, nil } @@ -187,16 +209,20 @@ defer cancel() p.Shutdown(ctx) ``` -### Per-client routing with source-address binding (split tunnelling) +### Per-client routing with source-address and interface binding (split tunnelling) ```go -decider := func(local, peer net.Addr) (net.IP, uint16, net.IP, any, error) { +decider := func(local, peer net.Addr) (net.IP, uint16, *proxy.LocalBinding, any, error) { host, _, _ := net.SplitHostPort(peer.String()) if isTunnelledIP(host) { - // Route through VPN interface, binding source to its local address. - return vpnGatewayIP, 443, net.ParseIP("10.0.0.1"), nil, nil + // 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 directGatewayIP, 443, nil, nil, nil + return vpnGatewayIP, 443, nil, nil, nil } p, err := proxy.NewTCPProxy(":443", "tcp4", decider, myLogger) @@ -236,10 +262,11 @@ go test -run='^$' -bench=BenchmarkUDP -benchmem # UDP only * **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 two goroutines per session (one - per direction) plus a shutdown watchdog; the UDP proxy spawns one goroutine - per session (upstream reader) plus a shared cleanup loop. All goroutines are - tracked via a `sync.WaitGroup`. +* **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"` @@ -253,3 +280,10 @@ go test -run='^$' -bench=BenchmarkUDP -benchmem # UDP only * **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). + diff --git a/service/splittun/proxy/bind_linux.go b/service/splittun/proxy/bind_linux.go new file mode 100644 index 00000000..dc7d17f4 --- /dev/null +++ b/service/splittun/proxy/bind_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package proxy + +import ( + "net" + "syscall" +) + +// applyBindToDevice configures d to bind all outgoing connections to the named +// network interface via the SO_BINDTODEVICE socket option. The option is set +// in d.Control, which the net package invokes on the raw file descriptor +// immediately after socket creation and before connect(2), ensuring the kernel +// routes the connection through the specified device regardless of the routing +// table. +// +// If iface is empty, d is left unchanged and no binding is performed. +// d.Control is overwritten; any previously set hook is discarded. +func applyBindToDevice(d *net.Dialer, iface string) { + if iface == "" { + return + } + d.Control = func(network, address string, c syscall.RawConn) error { + var innerErr error + err := c.Control(func(fd uintptr) { + innerErr = syscall.SetsockoptString( + int(fd), + syscall.SOL_SOCKET, + syscall.SO_BINDTODEVICE, + iface, + ) + }) + if err != nil { + return err + } + return innerErr + } +} diff --git a/service/splittun/proxy/bind_other.go b/service/splittun/proxy/bind_other.go new file mode 100644 index 00000000..de439c26 --- /dev/null +++ b/service/splittun/proxy/bind_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package proxy + +import "net" + +// applyBindToDevice is a no-op on non-Linux platforms; SO_BINDTODEVICE is a +// Linux-specific socket option and has no equivalent here. +func applyBindToDevice(_ *net.Dialer, _ string) {} diff --git a/service/splittun/proxy/common.go b/service/splittun/proxy/common.go index 1ba5acfd..d1ffd3cc 100644 --- a/service/splittun/proxy/common.go +++ b/service/splittun/proxy/common.go @@ -10,20 +10,33 @@ import ( // ─── Public API types ──────────────────────────────────────────────────────── +// 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 address, the optional local address to bind the outgoing -// connection to, and an optional extra context object. +// destination and optional local binding parameters for the outgoing socket. // -// local is the proxy's listen address; peer is the connecting client's -// address. +// 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 -// - localIP: optional local IP to use as the source address (nil = OS chooses) -// - extraInfo: optional user-defined object attached to the session context -// - err: non-nil rejects the session -type DeciderFunc func(local net.Addr, peer net.Addr) (remoteIP net.IP, remotePort uint16, localIP net.IP, extraInfo any, err error) +// - 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 only). +// - 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 structured logging interface expected by the proxies. // Pass nil to disable all logging. diff --git a/service/splittun/proxy/proxy_test.go b/service/splittun/proxy/proxy_test.go index 7ad9ea51..27d38891 100644 --- a/service/splittun/proxy/proxy_test.go +++ b/service/splittun/proxy/proxy_test.go @@ -16,7 +16,7 @@ import ( // passThroughDecider always routes to dest. func passThroughDecider(dest string) DeciderFunc { addr, _ := net.ResolveTCPAddr("tcp", dest) - return func(_, _ net.Addr) (net.IP, uint16, net.IP, any, error) { + return func(_, _ net.Addr) (net.IP, uint16, *LocalBinding, any, error) { if addr == nil { return nil, 0, nil, nil, fmt.Errorf("invalid dest %q", dest) } @@ -25,7 +25,7 @@ func passThroughDecider(dest string) DeciderFunc { } // refuseDecider always rejects sessions. -func refuseDecider(_ net.Addr, _ net.Addr) (net.IP, uint16, net.IP, any, error) { +func refuseDecider(_ net.Addr, _ net.Addr) (net.IP, uint16, *LocalBinding, any, error) { return nil, 0, nil, nil, fmt.Errorf("rejected") } @@ -434,7 +434,7 @@ func TestUDPProxy_MaxSessions(t *testing.T) { // Count how many sessions the decider accepts; reject beyond limit. var accepted atomic.Int32 const limit = 2 - decider := func(local, peer net.Addr) (net.IP, uint16, net.IP, any, error) { + decider := func(local, peer net.Addr) (net.IP, uint16, *LocalBinding, any, error) { if accepted.Load() >= limit { return nil, 0, nil, nil, fmt.Errorf("max sessions") } diff --git a/service/splittun/proxy/tcp_proxy.go b/service/splittun/proxy/tcp_proxy.go index 34835309..3de90b00 100644 --- a/service/splittun/proxy/tcp_proxy.go +++ b/service/splittun/proxy/tcp_proxy.go @@ -187,7 +187,7 @@ func (p *TCPProxy) handleConn(clientConn net.Conn) { defer clientConn.Close() // Determine upstream destination. - destIP, destPort, localAddr, extraInfo, err := p.decider(p.listener.Addr(), clientConn.RemoteAddr()) + destIP, destPort, binding, extraInfo, err := p.decider(p.listener.Addr(), clientConn.RemoteAddr()) if err != nil { p.log.Warnf("tcp proxy: decider rejected %s: %v", clientConn.RemoteAddr(), err) return @@ -216,8 +216,11 @@ func (p *TCPProxy) handleConn(clientConn net.Conn) { // DialContext is cancelled immediately if the proxy is shut down. dialer := net.Dialer{Timeout: p.cfg.DialTimeout} - if localAddr != nil { - dialer.LocalAddr = &net.TCPAddr{IP: localAddr} + if binding != nil && binding.IP != nil { + dialer.LocalAddr = &net.TCPAddr{IP: binding.IP} + } + if binding != nil { + applyBindToDevice(&dialer, binding.Interface) } upstreamConn, err := dialer.DialContext(p.shutdownCtx, p.network, destAddr) if err != nil { diff --git a/service/splittun/proxy/udp_proxy.go b/service/splittun/proxy/udp_proxy.go index 7096131e..b577db66 100644 --- a/service/splittun/proxy/udp_proxy.go +++ b/service/splittun/proxy/udp_proxy.go @@ -12,7 +12,9 @@ import ( type udpSession struct { connCtx *ConnContext // remote is the per-session UDP socket dialled to the upstream. - remote *net.UDPConn + // net.Conn is used so platform-specific dialers (e.g. SO_BINDTODEVICE on + // Linux) can return different concrete types without changing the callers. + remote net.Conn } // UDPProxy is a Layer-4 UDP proxy. It uses a single listening UDPConn and @@ -221,7 +223,7 @@ func (p *UDPProxy) handlePacket(clientAddr *net.UDPAddr, data []byte) { return } - destIP, destPort, localAddr, extraInfo, err := p.decider(p.conn.LocalAddr(), clientAddr) + destIP, destPort, binding, extraInfo, err := p.decider(p.conn.LocalAddr(), clientAddr) if err != nil { p.log.Warnf("udp proxy: decider rejected %s: %v", key, err) return @@ -234,11 +236,14 @@ func (p *UDPProxy) handlePacket(clientAddr *net.UDPAddr, data []byte) { p.cache.add(connCtx) remoteAddr := &net.UDPAddr{IP: destIP, Port: int(destPort)} - var localUDPAddr *net.UDPAddr - if localAddr != nil { - localUDPAddr = &net.UDPAddr{IP: localAddr} + d := net.Dialer{} + if binding != nil && binding.IP != nil { + d.LocalAddr = &net.UDPAddr{IP: binding.IP} } - remoteConn, err := net.DialUDP("udp", localUDPAddr, remoteAddr) + if binding != nil { + applyBindToDevice(&d, binding.Interface) + } + remoteConn, err := d.DialContext(sessCtx, "udp", remoteAddr.String()) if err != nil { p.cache.remove(connCtx) cancel() diff --git a/service/splittun/requests.go b/service/splittun/requests.go index d7b3f9e1..4e453998 100644 --- a/service/splittun/requests.go +++ b/service/splittun/requests.go @@ -12,6 +12,7 @@ import ( "github.com/safing/portmaster/service/netenv" "github.com/safing/portmaster/service/network" "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/splittun/proxy" ) // pendingRequestTTL is the maximum time a pending request waits for the proxy @@ -21,7 +22,7 @@ const pendingRequestTTL = 30 * time.Second type request struct { connInfo *network.Connection - bindIP net.IP + binding proxy.LocalBinding expiresAt time.Time } @@ -39,8 +40,7 @@ var ( // - "auto" - to try detecting "default" (non-VPN) interface automatically (not reliable) func AwaitRequest(connInfo *network.Connection, bindInterface string) (*network.SplitTunContext, error) { - var bindIP net.IP - var interfaceName string + var binding proxy.LocalBinding if bindInterface == "" || bindInterface == "auto" { // "auto" is the default and means to try detecting the "default" (non-VPN) interface automatically. // This is not reliable, but can be convenient for users who don't want to configure an interface. @@ -52,14 +52,14 @@ func AwaitRequest(connInfo *network.Connection, bindInterface string) (*network. var selectedIface *netenv.InterfaceInfo if connInfo.IPVersion == packet.IPv6 && ifaces.ForIPv6 != nil { selectedIface = ifaces.ForIPv6 - bindIP = selectedIface.IPv6 + binding.IP = selectedIface.IPv6 } else if connInfo.IPVersion == packet.IPv4 && ifaces.ForIPv4 != nil { selectedIface = ifaces.ForIPv4 - bindIP = selectedIface.IPv4 + binding.IP = selectedIface.IPv4 } else { return nil, fmt.Errorf("no suitable default physical interface found for %s", connInfo.IPVersion) } - interfaceName = selectedIface.Interface.Name + binding.Interface = selectedIface.Interface.Name } else { // Getting the interface IP address to bind the proxy connection to. iface, err := netenv.GetInterface(bindInterface) @@ -68,14 +68,14 @@ func AwaitRequest(connInfo *network.Connection, bindInterface string) (*network. } if connInfo.IPVersion == packet.IPv6 { - bindIP = iface.IPv6 + binding.IP = iface.IPv6 } else { - bindIP = iface.IPv4 + binding.IP = iface.IPv4 } - if bindIP == nil { + if binding.IP == nil { return nil, fmt.Errorf("interface %q has no usable address for %s", bindInterface, connInfo.IPVersion) } - interfaceName = iface.Interface.Name + binding.Interface = iface.Interface.Name } // Create unique key for the pending connection @@ -94,7 +94,7 @@ func AwaitRequest(connInfo *network.Connection, bindInterface string) (*network. pendingRequests[key] = &request{ connInfo: connInfo, - bindIP: bindIP, + binding: binding, expiresAt: time.Now().Add(pendingRequestTTL), } @@ -103,8 +103,8 @@ func AwaitRequest(connInfo *network.Connection, bindInterface string) (*network. scheduleCleanup() return &network.SplitTunContext{ - Interface: interfaceName, - IP: bindIP, + Interface: binding.Interface, + IP: binding.IP, }, nil } @@ -176,12 +176,13 @@ func consumeRequest(address string) (r *request, err error) { return nil, fmt.Errorf("no pending request for %s", address) } -// proxyDecider is called by the proxy when a new connection arrives, to determine where to forward it. -func proxyDecider(local net.Addr, peer net.Addr) (remoteIP net.IP, remotePort uint16, localIP net.IP, extraInfo any, err error) { +// proxyDecider is called by the proxy for each new connection to determine the +// upstream destination and local binding parameters. +func proxyDecider(local net.Addr, peer net.Addr) (remoteIP net.IP, remotePort uint16, binding *proxy.LocalBinding, extraInfo any, err error) { r, err := consumeRequest(peer.String()) if err != nil { return nil, 0, nil, nil, err } - return r.connInfo.Entity.IP, uint16(r.connInfo.Entity.Port), r.bindIP, r.connInfo, nil + return r.connInfo.Entity.IP, uint16(r.connInfo.Entity.Port), &r.binding, r.connInfo, nil }