mirror of
https://github.com/safing/portmaster.git
synced 2026-05-20 20:40:36 +00:00
splittun/proxy: add LocalBinding with SO_BINDTODEVICE support
Introduces LocalBinding{IP, Interface} to carry both source-address
and device binding in a single DeciderFunc return value. On Linux,
SO_BINDTODEVICE is applied via net.Dialer.Control before connect(2),
forcing traffic through the specified interface regardless of the
routing table. Non-Linux platforms get a no-op stub.
Wires LocalBinding through TCPProxy, UDPProxy, and splittun's
proxyDecider/AwaitRequest so split-tunnelled connections are bound
to the correct physical interface.
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user