From e2b25f17db0322e2913f81ad3f7ffd965836b354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 8 Jul 2026 00:10:00 +0800 Subject: [PATCH] Add bridge outbound --- adapter/platform.go | 21 + adapter/router.go | 4 +- constant/proxy.go | 3 + docs/configuration/outbound/bridge.md | 69 +++ docs/configuration/outbound/bridge.zh.md | 67 +++ docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/shared/pre-match.md | 4 +- docs/configuration/shared/pre-match.zh.md | 4 +- experimental/libbox/bridge_service_darwin.go | 69 +++ experimental/libbox/bridge_service_linux.go | 71 +++ experimental/libbox/bridge_stub.go | 9 + experimental/libbox/config.go | 8 + experimental/libbox/platform.go | 20 + experimental/libbox/service.go | 49 ++ go.mod | 6 +- go.sum | 8 +- include/registry.go | 2 + mkdocs.yml | 1 + option/bridge.go | 8 + protocol/bridge/backend.go | 178 ++++++ protocol/bridge/backend_darwin.go | 295 ++++++++++ protocol/bridge/backend_linux.go | 461 ++++++++++++++++ protocol/bridge/backend_stub.go | 16 + protocol/bridge/linkname_darwin.go | 11 + protocol/bridge/netfilter_linux.go | 540 +++++++++++++++++++ protocol/bridge/outbound.go | 90 ++++ protocol/bridge/packet.go | 121 +++++ protocol/bridge/pf_darwin.go | 379 +++++++++++++ protocol/bridge/route_darwin.go | 223 ++++++++ protocol/bridge/rules_darwin.go | 285 ++++++++++ protocol/bridge/service.go | 99 ++++ protocol/bridge/service_darwin.go | 208 +++++++ protocol/bridge/service_linux.go | 251 +++++++++ route/route.go | 16 + 35 files changed, 3586 insertions(+), 12 deletions(-) create mode 100644 docs/configuration/outbound/bridge.md create mode 100644 docs/configuration/outbound/bridge.zh.md create mode 100644 experimental/libbox/bridge_service_darwin.go create mode 100644 experimental/libbox/bridge_service_linux.go create mode 100644 experimental/libbox/bridge_stub.go create mode 100644 option/bridge.go create mode 100644 protocol/bridge/backend.go create mode 100644 protocol/bridge/backend_darwin.go create mode 100644 protocol/bridge/backend_linux.go create mode 100644 protocol/bridge/backend_stub.go create mode 100644 protocol/bridge/linkname_darwin.go create mode 100644 protocol/bridge/netfilter_linux.go create mode 100644 protocol/bridge/outbound.go create mode 100644 protocol/bridge/packet.go create mode 100644 protocol/bridge/pf_darwin.go create mode 100644 protocol/bridge/route_darwin.go create mode 100644 protocol/bridge/rules_darwin.go create mode 100644 protocol/bridge/service.go create mode 100644 protocol/bridge/service_darwin.go create mode 100644 protocol/bridge/service_linux.go diff --git a/adapter/platform.go b/adapter/platform.go index 448a15daa..ce9bd9dca 100644 --- a/adapter/platform.go +++ b/adapter/platform.go @@ -51,6 +51,27 @@ type PlatformInterface interface { LookupSFTPServer() (string, error) ReadSystemSSHHostKey() ([]byte, error) TailscaleHostname() string + + UsePlatformBridge() bool + CreateBridge(options BridgeOptions) (BridgeSession, error) +} + +type BridgeOptions struct { + BridgeName string + MTU uint32 + Inet4Port netip.Addr + Inet6Port netip.Addr + Interface string + RuleIndex int + RouteTable int +} + +type BridgeSession interface { + FileDescriptor() int + Name() string + Inet6Active() bool + SetEgress(interfaceName string) error + Close() error } type PlatformUser struct { diff --git a/adapter/router.go b/adapter/router.go index a87912b23..592882e95 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -4,6 +4,7 @@ import ( "context" "net" "net/netip" + "time" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/gtcpip/header" @@ -42,6 +43,7 @@ type PreMatchResult struct { Action PreMatchAction Outbound Outbound Destination netip.AddrPort + UDPTimeout time.Duration NewTracker func() tun.FlowTracker } @@ -75,7 +77,7 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8, if !isPort { return tun.FlowVerdict{Action: tun.ActionAccept} } - verdict := tun.FlowVerdict{Action: tun.ActionFlow, Port: port, NewTracker: result.NewTracker} + verdict := tun.FlowVerdict{Action: tun.ActionFlow, Port: port, UDPTimeout: result.UDPTimeout, NewTracker: result.NewTracker} if result.Destination.IsValid() { destinationPort := result.Destination.Port() if networkName == N.NetworkICMP { diff --git a/constant/proxy.go b/constant/proxy.go index 60bb806a0..e91164202 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -5,6 +5,7 @@ const ( TypeRedirect = "redirect" TypeTProxy = "tproxy" TypeDirect = "direct" + TypeBridge = "bridge" TypeBlock = "block" TypeDNS = "dns" TypeSOCKS = "socks" @@ -56,6 +57,8 @@ func ProxyDisplayName(proxyType string) string { return "TProxy" case TypeDirect: return "Direct" + case TypeBridge: + return "Bridge" case TypeBlock: return "Block" case TypeDNS: diff --git a/docs/configuration/outbound/bridge.md b/docs/configuration/outbound/bridge.md new file mode 100644 index 000000000..f3223c35c --- /dev/null +++ b/docs/configuration/outbound/bridge.md @@ -0,0 +1,69 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Bridge + +!!! quote "" + + Requires privileges. Supported on Linux, macOS, rooted Android, and jailbroken iOS. + + For graphical clients: on macOS, only available in the standalone version and requires the + Root Helper; on Android, requires root permission; on iOS, requires jailbreak. + +`bridge` is the L3 counterpart of the `direct` outbound: it forwards L3 connections +(TCP, UDP and ICMP) directly out of a network interface. Route L3 traffic to it from a TUN +or other L3 endpoints via the `route` action in +[Pre-match](/configuration/shared/pre-match/); L4 connections will be rejected. + +### Structure + +```json +{ + "type": "bridge", + "tag": "bridge-out", + + "interface": "", + "bridge_name": "", + "iproute2_table_index": 0, + "iproute2_rule_index": 0 +} +``` + +### Fields + +#### interface + +Interface name for forwarded traffic to egress. + +The default interface will be used by default. + +Forwarded traffic will be dropped while the interface is unavailable. + +#### bridge_name + +Custom bridge TUN interface name prefix, `bridge` is used by default. + +Not effective on Apple platforms. + +#### iproute2_table_index + +!!! quote "" + + Only supported on Linux, and only takes effect when `interface` is set. + +Linux iproute2 table index for pinned egress routes. + +`2200` + instance index is used by default. + +#### iproute2_rule_index + +!!! quote "" + + Only supported on Linux. + +Linux iproute2 rule start index. + +`100` is used by default. diff --git a/docs/configuration/outbound/bridge.zh.md b/docs/configuration/outbound/bridge.zh.md new file mode 100644 index 000000000..76f574117 --- /dev/null +++ b/docs/configuration/outbound/bridge.zh.md @@ -0,0 +1,67 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# Bridge + +!!! quote "" + + 需要特权。支持 Linux、macOS、rooted Android 和越狱 iOS。 + + 对于图形客户端:macOS 仅独立版本可用,且需要 Root Helper;Android 需要 root 权限;iOS 需要越狱。 + +`bridge` 是 `direct` 出站的 L3 版本:它将 L3 连接(TCP、UDP 和 ICMP)直接从网络接口转发出去。 +通过[预匹配](/zh/configuration/shared/pre-match/)中的 `route` 动作,将 L3 流量从 TUN +或其他 L3 endpoints 路由到它;L4 连接将被拒绝。 + +### 结构 + +```json +{ + "type": "bridge", + "tag": "bridge-out", + + "interface": "", + "bridge_name": "", + "iproute2_table_index": 0, + "iproute2_rule_index": 0 +} +``` + +### 字段 + +#### interface + +转发流量流出的网络接口名称。 + +默认使用默认接口。 + +接口不可用期间,转发流量将被丢弃。 + +#### bridge_name + +自定义 bridge TUN 接口名前缀,默认使用 `bridge`。 + +在 Apple 平台上无效。 + +#### iproute2_table_index + +!!! quote "" + + 仅支持 Linux,且仅在设置了 `interface` 时生效。 + +用于固定出口路由的 Linux iproute2 路由表索引。 + +默认使用 `2200` + 实例索引。 + +#### iproute2_rule_index + +!!! quote "" + + 仅支持 Linux。 + +Linux iproute2 规则起始索引。 + +默认使用 `100`。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 0411ec045..2e37bdd58 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -18,6 +18,7 @@ | Type | Format | |----------------|--------------------------------| | `direct` | [Direct](./direct/) | +| `bridge` | [Bridge](./bridge/) | | `block` | [Block](./block/) | | `socks` | [SOCKS](./socks/) | | `http` | [HTTP](./http/) | diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index 5c7b78523..d2203de8c 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -18,6 +18,7 @@ | 类型 | 格式 | |----------------|--------------------------------| | `direct` | [Direct](./direct/) | +| `bridge` | [Bridge](./bridge/) | | `block` | [Block](./block/) | | `socks` | [SOCKS](./socks/) | | `http` | [HTTP](./http/) | diff --git a/docs/configuration/shared/pre-match.md b/docs/configuration/shared/pre-match.md index 6ad77ebfd..f88f76761 100644 --- a/docs/configuration/shared/pre-match.md +++ b/docs/configuration/shared/pre-match.md @@ -43,8 +43,8 @@ without going through L3 to L4 translation. Supported targets: -- ICMP connections: Direct outbounds and WireGuard / Tailscale endpoints. -- TCP and UDP connections: WireGuard and Tailscale endpoints. +- ICMP connections: Direct and Bridge outbounds, and WireGuard / Tailscale endpoints. +- TCP and UDP connections: Bridge outbounds, and WireGuard / Tailscale endpoints. L3 forwarding also applies when no rule matches and the default outbound is a supported target; for outbound groups, the currently selected outbound is used. diff --git a/docs/configuration/shared/pre-match.zh.md b/docs/configuration/shared/pre-match.zh.md index 5f56e55ac..9e41d239d 100644 --- a/docs/configuration/shared/pre-match.zh.md +++ b/docs/configuration/shared/pre-match.zh.md @@ -39,8 +39,8 @@ icon: material/new-box 支持的目标: -- ICMP 连接:direct 出站和 WireGuard / Tailscale 端点。 -- TCP 和 UDP 连接:WireGuard 和 Tailscale 端点。 +- ICMP 连接:direct 和 bridge 出站以及 WireGuard / Tailscale 端点。 +- TCP 和 UDP 连接:bridge 出站以及 WireGuard / Tailscale 端点。 当没有规则匹配且默认出站为受支持的目标时,L3 转发同样生效;对于出站组,使用当前选中的出站。 diff --git a/experimental/libbox/bridge_service_darwin.go b/experimental/libbox/bridge_service_darwin.go new file mode 100644 index 000000000..b9919afc4 --- /dev/null +++ b/experimental/libbox/bridge_service_darwin.go @@ -0,0 +1,69 @@ +//go:build darwin + +package libbox + +import ( + "context" + "net/netip" + "os" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/protocol/bridge" + E "github.com/sagernet/sing/common/exceptions" +) + +func NewBridgeService(options *BridgeOptions) (BridgeSession, error) { + if options == nil { + return nil, E.New("missing bridge options") + } + serviceOptions := bridge.ServiceOptions{ + MTU: int(options.MTU), + Interface: options.Interface, + } + if options.Inet4Port != "" { + inet4Port, err := netip.ParseAddr(options.Inet4Port) + if err != nil { + return nil, E.Cause(err, "parse inet4 port address") + } + serviceOptions.Inet4Port = inet4Port + } + if options.Inet6Port != "" { + inet6Port, err := netip.ParseAddr(options.Inet6Port) + if err != nil { + return nil, E.Cause(err, "parse inet6 port address") + } + serviceOptions.Inet6Port = inet6Port + } + logFactory := log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now()}, os.Stderr, "", nil, false) + serviceOptions.Logger = logFactory.Logger() + service, err := bridge.NewService(serviceOptions) + if err != nil { + return nil, err + } + return &bridgeServiceSession{service}, nil +} + +type bridgeServiceSession struct { + service *bridge.Service +} + +func (s *bridgeServiceSession) FileDescriptor() int32 { + return int32(s.service.FileDescriptor()) +} + +func (s *bridgeServiceSession) Name() string { + return s.service.Name() +} + +func (s *bridgeServiceSession) Inet6Active() bool { + return s.service.Inet6Active() +} + +func (s *bridgeServiceSession) SetEgress(interfaceName string) error { + return s.service.SetEgress(interfaceName) +} + +func (s *bridgeServiceSession) Close() error { + return s.service.Close() +} diff --git a/experimental/libbox/bridge_service_linux.go b/experimental/libbox/bridge_service_linux.go new file mode 100644 index 000000000..0e9920667 --- /dev/null +++ b/experimental/libbox/bridge_service_linux.go @@ -0,0 +1,71 @@ +//go:build linux + +package libbox + +import ( + "context" + "net/netip" + "os" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/protocol/bridge" + E "github.com/sagernet/sing/common/exceptions" +) + +func NewBridgeService(options *BridgeOptions) (BridgeSession, error) { + if options == nil { + return nil, E.New("missing bridge options") + } + serviceOptions := bridge.ServiceOptions{ + BridgeName: options.BridgeName, + MTU: int(options.MTU), + RuleIndex: int(options.RuleIndex), + RouteTable: int(options.RouteTable), + } + if options.Inet4Port != "" { + inet4Port, err := netip.ParseAddr(options.Inet4Port) + if err != nil { + return nil, E.Cause(err, "parse inet4 port address") + } + serviceOptions.Inet4Port = inet4Port + } + if options.Inet6Port != "" { + inet6Port, err := netip.ParseAddr(options.Inet6Port) + if err != nil { + return nil, E.Cause(err, "parse inet6 port address") + } + serviceOptions.Inet6Port = inet6Port + } + logFactory := log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now()}, os.Stderr, "", nil, false) + serviceOptions.Logger = logFactory.Logger() + service, err := bridge.NewService(serviceOptions) + if err != nil { + return nil, err + } + return &bridgeServiceSession{service}, nil +} + +type bridgeServiceSession struct { + service *bridge.Service +} + +func (s *bridgeServiceSession) FileDescriptor() int32 { + return int32(s.service.FileDescriptor()) +} + +func (s *bridgeServiceSession) Name() string { + return s.service.Name() +} + +func (s *bridgeServiceSession) Inet6Active() bool { + return s.service.Inet6Active() +} + +func (s *bridgeServiceSession) SetEgress(interfaceName string) error { + return s.service.SetEgress(interfaceName) +} + +func (s *bridgeServiceSession) Close() error { + return s.service.Close() +} diff --git a/experimental/libbox/bridge_stub.go b/experimental/libbox/bridge_stub.go new file mode 100644 index 000000000..e053b9252 --- /dev/null +++ b/experimental/libbox/bridge_stub.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package libbox + +import E "github.com/sagernet/sing/common/exceptions" + +func NewBridgeService(options *BridgeOptions) (BridgeSession, error) { + return nil, E.New("bridge service not supported on this platform") +} diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index d9c52db0d..f2d379dea 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -181,6 +181,14 @@ func (s *platformInterfaceStub) TailscaleHostname() string { return "" } +func (s *platformInterfaceStub) UsePlatformBridge() bool { + return false +} + +func (s *platformInterfaceStub) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, os.ErrInvalid +} + func (s *platformInterfaceStub) LookupUser(username string) (*adapter.PlatformUser, error) { return nil, os.ErrInvalid } diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go index 39bbc4db0..9e5eed71c 100644 --- a/experimental/libbox/platform.go +++ b/experimental/libbox/platform.go @@ -27,6 +27,26 @@ type PlatformInterface interface { LookupSFTPServer() (string, error) ReadSystemSSHHostKey() (string, error) TailscaleHostname() string + UsePlatformBridge() bool + CreateBridge(options *BridgeOptions) (BridgeSession, error) +} + +type BridgeOptions struct { + BridgeName string + MTU int32 + Inet4Port string + Inet6Port string + Interface string + RuleIndex int32 + RouteTable int32 +} + +type BridgeSession interface { + FileDescriptor() int32 + Name() string + Inet6Active() bool + SetEgress(interfaceName string) error + Close() error } type PlatformUser struct { diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 9b37fa322..46af69482 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -285,6 +285,55 @@ func (w *platformInterfaceWrapper) TailscaleHostname() string { return w.iif.TailscaleHostname() } +func (w *platformInterfaceWrapper) UsePlatformBridge() bool { + return w.iif.UsePlatformBridge() +} + +func (w *platformInterfaceWrapper) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + bridgeOptions := &BridgeOptions{ + BridgeName: options.BridgeName, + MTU: int32(options.MTU), + Interface: options.Interface, + RuleIndex: int32(options.RuleIndex), + RouteTable: int32(options.RouteTable), + } + if options.Inet4Port.IsValid() { + bridgeOptions.Inet4Port = options.Inet4Port.String() + } + if options.Inet6Port.IsValid() { + bridgeOptions.Inet6Port = options.Inet6Port.String() + } + session, err := w.iif.CreateBridge(bridgeOptions) + if err != nil { + return nil, err + } + return &bridgeSessionWrapper{session}, nil +} + +type bridgeSessionWrapper struct { + session BridgeSession +} + +func (w *bridgeSessionWrapper) FileDescriptor() int { + return int(w.session.FileDescriptor()) +} + +func (w *bridgeSessionWrapper) Name() string { + return w.session.Name() +} + +func (w *bridgeSessionWrapper) Inet6Active() bool { + return w.session.Inet6Active() +} + +func (w *bridgeSessionWrapper) SetEgress(interfaceName string) error { + return w.session.SetEgress(interfaceName) +} + +func (w *bridgeSessionWrapper) Close() error { + return w.session.Close() +} + func (w *platformInterfaceWrapper) LookupUser(username string) (*adapter.PlatformUser, error) { platformUser, err := w.iif.LookupUser(username) if err != nil { diff --git a/go.mod b/go.mod index 436a0a227..568fe4841 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,8 @@ require ( github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 + github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a + github.com/sagernet/nftables v0.3.0-mod.3 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 @@ -49,7 +51,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814 - github.com/sagernet/sing-tun v0.8.12-0.20260706153816-c4a2d2afd8b5 + github.com/sagernet/sing-tun v0.8.12-0.20260707160752-9162aa68ac1e github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 @@ -148,8 +150,6 @@ require ( github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-mod.2 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect diff --git a/go.sum b/go.sum index a042c8d10..403bb7c8d 100644 --- a/go.sum +++ b/go.sum @@ -254,8 +254,8 @@ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje+vW5Q0OQ= -github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/nftables v0.3.0-mod.3 h1:CVfbVTd3Z/LQVc1Z3c1hpiriplJ4xDVHjfQCETiN9RA= +github.com/sagernet/nftables v0.3.0-mod.3/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= @@ -274,8 +274,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814 h1:xfnkRpjVRVeJhVvDZA8PzTLlKGTb1o2kdI4uv1YymXo= github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260706153816-c4a2d2afd8b5 h1:NKQ3ZWhUrWFqWLP1CxUFAX1e4/JOMMF2TbjkOYzJWj4= -github.com/sagernet/sing-tun v0.8.12-0.20260706153816-c4a2d2afd8b5/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-tun v0.8.12-0.20260707160752-9162aa68ac1e h1:cMMz0qQtCGT1CxXKo2mheM0hFlPaosYBWBqwZHdokYE= +github.com/sagernet/sing-tun v0.8.12-0.20260707160752-9162aa68ac1e/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= diff --git a/include/registry.go b/include/registry.go index 6ea32e1bd..28cbdd3cf 100644 --- a/include/registry.go +++ b/include/registry.go @@ -21,6 +21,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/protocol/anytls" "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/bridge" "github.com/sagernet/sing-box/protocol/direct" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" @@ -80,6 +81,7 @@ func OutboundRegistry() *outbound.Registry { registry := outbound.NewRegistry() direct.RegisterOutbound(registry) + bridge.RegisterOutbound(registry) block.RegisterOutbound(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 3623c8a52..525413174 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -167,6 +167,7 @@ nav: - Outbound: - configuration/outbound/index.md - Direct: configuration/outbound/direct.md + - Bridge: configuration/outbound/bridge.md - Block: configuration/outbound/block.md - SOCKS: configuration/outbound/socks.md - HTTP: configuration/outbound/http.md diff --git a/option/bridge.go b/option/bridge.go new file mode 100644 index 000000000..0cbf4b40c --- /dev/null +++ b/option/bridge.go @@ -0,0 +1,8 @@ +package option + +type BridgeOutboundOptions struct { + Interface string `json:"interface,omitempty"` + BridgeName string `json:"bridge_name,omitempty"` + IPRoute2TableIndex int `json:"iproute2_table_index,omitempty"` + IPRoute2RuleIndex int `json:"iproute2_rule_index,omitempty"` +} diff --git a/protocol/bridge/backend.go b/protocol/bridge/backend.go new file mode 100644 index 000000000..1d782e4d3 --- /dev/null +++ b/protocol/bridge/backend.go @@ -0,0 +1,178 @@ +//go:build linux || darwin + +package bridge + +import ( + "context" + "net/netip" + "slices" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +type sysctlState struct { + name string + value string +} + +type backendBase struct { + ctx context.Context + logger logger.ContextLogger + networkManager adapter.NetworkManager + tag string + + index uint32 + bridgeName string + tunName string + inet4Port netip.Addr + inet6Port netip.Addr + + boundInterface string + + tunInterface tun.Tun + + returnAccess sync.Mutex + returnPaths []tun.Return + + egressAccess sync.Mutex + forwardingRestore []sysctlState + unregister func() + + session adapter.BridgeSession + currentEgress string + + closeOnce sync.Once + closed chan struct{} + readDone chan struct{} +} + +func (b *backendBase) init(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) error { + index, err := allocateBridgeIndex() + if err != nil { + return err + } + b.ctx = ctx + b.logger = logger + b.networkManager = networkManager + b.tag = tag + b.index = index + b.bridgeName = options.BridgeName + if b.bridgeName == "" { + b.bridgeName = "bridge" + } + b.boundInterface = options.Interface + b.inet4Port = addressAt(bridgeInet4Base, index) + b.inet6Port = addressAt(bridgeInet6Base, index) + return nil +} + +func (b *backendBase) PortAddresses() (netip.Addr, netip.Addr) { + return b.inet4Port, b.inet6Port +} + +func (b *backendBase) AttachReturn(returnPath tun.Return) error { + b.returnAccess.Lock() + defer b.returnAccess.Unlock() + if slices.Contains(b.returnPaths, returnPath) { + return nil + } + b.returnPaths = append(b.returnPaths[:len(b.returnPaths):len(b.returnPaths)], returnPath) + return nil +} + +func (b *backendBase) DetachReturn(returnPath tun.Return) error { + b.returnAccess.Lock() + defer b.returnAccess.Unlock() + returnPaths := make([]tun.Return, 0, len(b.returnPaths)) + for _, existing := range b.returnPaths { + if existing != returnPath { + returnPaths = append(returnPaths, existing) + } + } + b.returnPaths = returnPaths + return nil +} + +func (b *backendBase) syncSessionEgress() { + b.egressAccess.Lock() + defer b.egressAccess.Unlock() + select { + case <-b.closed: + return + default: + } + egress := b.resolveEgress() + if egress == b.currentEgress { + return + } + err := b.session.SetEgress(egress) + if err != nil { + b.logger.Debug(E.Cause(err, "apply bridge egress ", egress)) + return + } + b.currentEgress = egress + if egress == "" { + b.logger.Debug("bridge egress unavailable, dropping forwarded traffic") + } else { + b.logger.Debug("bridge egress ", egress) + } +} + +func (b *backendBase) resolveEgress() string { + if b.boundInterface != "" { + return b.boundInterface + } + monitor := b.networkManager.InterfaceMonitor() + if monitor == nil { + return "" + } + defaultInterface := monitor.DefaultInterface() + if defaultInterface == nil { + return "" + } + return defaultInterface.Name +} + +func (b *backendBase) readLoop() { + defer close(b.readDone) + buffer := make([]byte, tun.PacketOffset+bridgeTunMTU) + for { + n, err := b.tunInterface.Read(buffer) + if err != nil { + select { + case <-b.closed: + default: + b.logger.Debug(E.Cause(err, "bridge tun read")) + } + return + } + if n <= tun.PacketOffset { + continue + } + packet := buffer[tun.PacketOffset:n] + // On checksum-offloading NICs (notably virtio) the kernel leaves the L4 + // checksum uncomputed when the forwarding path TXes to a tun; recompute it. + fixReturnChecksum(packet) + b.deliverReturn(packet) + } +} + +func (b *backendBase) deliverReturn(packet []byte) { + b.returnAccess.Lock() + returnPaths := b.returnPaths + b.returnAccess.Unlock() + for _, returnPath := range returnPaths { + headroom := returnPath.ReturnHeadroom() + buffer := make([]byte, headroom+len(packet)) + copy(buffer[headroom:], packet) + unconsumed := returnPath.ReturnPackets([][]byte{buffer}) + if len(unconsumed) == 0 { + return + } + } +} diff --git a/protocol/bridge/backend_darwin.go b/protocol/bridge/backend_darwin.go new file mode 100644 index 000000000..548824fce --- /dev/null +++ b/protocol/bridge/backend_darwin.go @@ -0,0 +1,295 @@ +package bridge + +import ( + "context" + "net/netip" + "slices" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" +) + +var ( + bridgeInet4LocalBase = netip.MustParseAddr("198.51.100.1") + bridgeInet6LocalBase = netip.MustParseAddr("2001:db8:1::1") +) + +type backendDarwin struct { + backendBase + + // anchorName lives under com.apple/* so the stock pf.conf's wildcard + // nat/scrub/anchor references evaluate our rules without editing it. + anchorName string + + inet4Local netip.Addr + inet6Local netip.Addr + + writeAccess sync.Mutex + writeBuffer []byte + + pfDevice *pfDevice + pfToken uint64 + + currentRules []pfAnchorRule + + platform adapter.PlatformInterface +} + +func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) { + instance := &backendDarwin{ + writeBuffer: make([]byte, tun.PacketOffset+maxPacketLength), + } + err := instance.init(ctx, logger, networkManager, tag, options) + if err != nil { + return nil, err + } + instance.inet4Local = addressAt(bridgeInet4LocalBase, instance.index) + instance.inet6Local = addressAt(bridgeInet6LocalBase, instance.index) + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + if platformInterface != nil && platformInterface.UsePlatformBridge() { + instance.platform = platformInterface + } + return instance, nil +} + +func (b *backendDarwin) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := b.start() + if err != nil { + b.Close() + return err + } + return nil +} + +func (b *backendDarwin) start() error { + if b.platform != nil { + return b.startPlatform() + } + b.tunName = tun.CalculateInterfaceName(b.bridgeName) + b.anchorName = "com.apple/sing-box-" + b.tunName + tunInterface, err := tun.New(tun.Options{ + Name: b.tunName, + MTU: bridgeTunMTU, + AutoRoute: false, + InterfaceMonitor: b.networkManager.InterfaceMonitor(), + Logger: b.logger, + }) + if err != nil { + return E.Cause(err, "create bridge tun") + } + b.tunInterface = tunInterface + err = tunInterface.Start() + if err != nil { + return E.Cause(err, "start bridge tun") + } + b.forwardingRestore = enableDarwinForwarding(b.logger, b.inet4Port.IsValid(), b.inet6Port.IsValid()) + err = assignBridgePortAddress(b.tunName, b.inet4Local, b.inet4Port) + if err != nil { + return E.Cause(err, "add bridge route") + } + err = assignBridgePortAddress(b.tunName, b.inet6Local, b.inet6Port) + if err != nil { + b.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding")) + b.inet6Port = netip.Addr{} + } + err = b.enablePf() + if err != nil { + return E.Cause(err, "enable pf") + } + b.closed = make(chan struct{}) + b.readDone = make(chan struct{}) + b.registerMonitors(b.syncEgress) + b.syncEgress() + go b.readLoop() + b.logger.Info("bridge started at ", b.tunName, " (masquerade, egress ", b.egressLabel(), ")") + return nil +} + +func (b *backendDarwin) startPlatform() error { + session, err := b.platform.CreateBridge(adapter.BridgeOptions{ + BridgeName: b.bridgeName, + MTU: bridgeTunMTU, + Inet4Port: b.inet4Port, + Inet6Port: b.inet6Port, + Interface: b.boundInterface, + }) + if err != nil { + return E.Cause(err, "create bridge") + } + b.session = session + b.tunName = session.Name() + if !session.Inet6Active() { + b.inet6Port = netip.Addr{} + } + tunInterface, err := tun.New(tun.Options{ + Name: b.tunName, + MTU: bridgeTunMTU, + FileDescriptor: session.FileDescriptor(), + Logger: b.logger, + EXP_ExternalConfiguration: true, + }) + if err != nil { + return E.Cause(err, "create bridge tun") + } + b.tunInterface = tunInterface + err = tunInterface.Start() + if err != nil { + return E.Cause(err, "start bridge tun") + } + b.closed = make(chan struct{}) + b.readDone = make(chan struct{}) + b.registerMonitors(b.syncSessionEgress) + b.syncSessionEgress() + go b.readLoop() + b.logger.Info("bridge started at ", b.tunName, " (platform, egress ", b.egressLabel(), ")") + return nil +} + +func (b *backendDarwin) registerMonitors(syncFunc func()) { + var unregisterFuncs []func() + networkMonitor := b.networkManager.NetworkMonitor() + if networkMonitor != nil { + networkElement := networkMonitor.RegisterCallback(syncFunc) + unregisterFuncs = append(unregisterFuncs, func() { networkMonitor.UnregisterCallback(networkElement) }) + } else if b.boundInterface != "" { + b.logger.Debug("network monitor unavailable, pinned egress will not track interface changes") + } + if b.boundInterface == "" { + interfaceMonitor := b.networkManager.InterfaceMonitor() + if interfaceMonitor != nil { + interfaceElement := interfaceMonitor.RegisterCallback(func(_ *control.Interface, _ int) { syncFunc() }) + unregisterFuncs = append(unregisterFuncs, func() { interfaceMonitor.UnregisterCallback(interfaceElement) }) + } + } + if len(unregisterFuncs) > 0 { + b.unregister = func() { + for _, unregisterFunc := range unregisterFuncs { + unregisterFunc() + } + } + } +} + +func (b *backendDarwin) egressLabel() string { + if b.boundInterface != "" { + return b.boundInterface + } + return "auto" +} + +func (b *backendDarwin) Close() error { + b.closeOnce.Do(func() { + if b.closed != nil { + close(b.closed) + } + if b.unregister != nil { + b.unregister() + } + if b.pfDevice != nil && b.anchorName != "" { + b.egressAccess.Lock() + _ = b.pfDevice.LoadAnchor(b.anchorName, nil) + b.egressAccess.Unlock() + } + restoreDarwinForwarding(b.forwardingRestore) + b.forwardingRestore = nil + if b.pfDevice != nil { + if b.pfToken != 0 { + _ = b.pfDevice.StopReference(b.pfToken) + } + _ = b.pfDevice.Close() + } + if b.tunInterface != nil { + b.tunInterface.Close() + } + if b.readDone != nil { + <-b.readDone + } + if b.session != nil { + _ = b.session.Close() + } + releaseBridgeIndex(b.index) + }) + return nil +} + +// Zero tells the dispatcher not to clamp the TCP MSS or fragment; pf and the +// host kernel do both on the forwarding path instead (see buildBridgeAnchorRules). +func (b *backendDarwin) PortMTU() uint32 { + return 0 +} + +func (b *backendDarwin) WritePackets(packets [][]byte) error { + b.writeAccess.Lock() + defer b.writeAccess.Unlock() + for _, packet := range packets { + if len(packet) == 0 || len(packet) > maxPacketLength { + continue + } + ipVersion := header.IPVersion(packet) + if ipVersion != header.IPv4Version && ipVersion != header.IPv6Version { + continue + } + buffer := b.writeBuffer[:tun.PacketOffset+len(packet)] + tun.PacketFillHeader(buffer, ipVersion) + copy(buffer[tun.PacketOffset:], packet) + _, err := b.tunInterface.Write(buffer) + if err != nil { + return err + } + } + return nil +} + +func (b *backendDarwin) syncEgress() { + b.egressAccess.Lock() + defer b.egressAccess.Unlock() + select { + case <-b.closed: + return + default: + } + egress := b.resolveEgress() + var rules []pfAnchorRule + if egress != "" { + rules = buildBridgeAnchorRules(b.logger, b.tunName, egress, b.boundInterface, b.inet4Port, b.inet6Port) + } + if slices.Equal(rules, b.currentRules) { + return + } + err := b.pfDevice.LoadAnchor(b.anchorName, rules) + if err != nil { + b.logger.Debug(E.Cause(err, "apply bridge egress ", egress)) + return + } + b.currentRules = rules + if len(rules) == 0 { + b.logger.Debug("bridge egress unavailable, dropping forwarded traffic") + } else { + b.logger.Debug("bridge egress ", egress) + } +} + +func (b *backendDarwin) enablePf() error { + device, err := openPfDevice() + if err != nil { + return err + } + token, err := device.StartReference() + if err != nil { + _ = device.Close() + return err + } + b.pfDevice = device + b.pfToken = token + return nil +} diff --git a/protocol/bridge/backend_linux.go b/protocol/bridge/backend_linux.go new file mode 100644 index 000000000..9111b2c8b --- /dev/null +++ b/protocol/bridge/backend_linux.go @@ -0,0 +1,461 @@ +package bridge + +import ( + "context" + "net/netip" + "sync" + + "github.com/sagernet/netlink" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" + + "golang.org/x/sys/unix" +) + +const ( + defaultBridgeRuleIndex = 100 + defaultBridgeTableIndexBase = 2200 + bridgeWriteBatchSize = 32 +) + +type backendLinux struct { + backendBase + + nftTableName string + routeTable int + ruleIndex int + + platform adapter.PlatformInterface + + batchTUN tun.LinuxTUN + + writeAccess sync.Mutex + writeHeadroom int + writeBuffers [][]byte + + clampMTU int +} + +func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) { + instance := &backendLinux{} + err := instance.init(ctx, logger, networkManager, tag, options) + if err != nil { + return nil, err + } + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + if platformInterface != nil && platformInterface.UsePlatformBridge() { + instance.platform = platformInterface + } + instance.ruleIndex = options.IPRoute2RuleIndex + if instance.ruleIndex == 0 { + instance.ruleIndex = defaultBridgeRuleIndex + } + if instance.boundInterface != "" || instance.platform != nil { + instance.routeTable = options.IPRoute2TableIndex + if instance.routeTable == 0 { + instance.routeTable = defaultBridgeTableIndexBase + int(instance.index) + } + } + return instance, nil +} + +func (b *backendLinux) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + err := b.start() + if err != nil { + b.Close() + return err + } + return nil +} + +func (b *backendLinux) start() error { + if b.platform != nil { + return b.startPlatform() + } + b.tunName = tun.CalculateInterfaceName(b.bridgeName) + b.nftTableName = "sing-box-" + b.tunName + tunInterface, err := tun.New(tun.Options{ + Name: b.tunName, + MTU: bridgeTunMTU, + GSO: true, + AutoRoute: false, + InterfaceMonitor: b.networkManager.InterfaceMonitor(), + Logger: b.logger, + }) + if err != nil { + return E.Cause(err, "create bridge tun") + } + b.tunInterface = tunInterface + err = tunInterface.Start() + if err != nil { + return E.Cause(err, "start bridge tun") + } + linuxTUN := tunInterface.(tun.LinuxTUN) + if linuxTUN.BatchSize() > 1 { + b.batchTUN = linuxTUN + b.writeHeadroom = linuxTUN.FrontHeadroom() + b.writeBuffers = make([][]byte, bridgeWriteBatchSize) + for i := range b.writeBuffers { + // handleGRO coalesces same-flow packets by appending into the first + // packet's buffer capacity, up to the 0xffff total length limit. + b.writeBuffers[i] = make([]byte, b.writeHeadroom+maxPacketLength) + } + } + inet6Active, err := setupBridgeNetfilter(b.logger, b.nftTableName, b.tunName, b.inet6Port.IsValid()) + if err != nil { + return E.Cause(err, "set up bridge netfilter") + } + if !inet6Active { + b.inet6Port = netip.Addr{} + } + b.forwardingRestore = enableBridgeForwarding(b.logger, b.tunName, b.inet4Port.IsValid(), b.inet6Port.IsValid()) + if b.boundInterface != "" { + b.syncEgress() + } + err = setupBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port) + if err != nil { + return E.Cause(err, "set up bridge routing") + } + err = setupBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port) + if err != nil { + b.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding")) + removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port) + b.inet6Port = netip.Addr{} + } + b.closed = make(chan struct{}) + b.readDone = make(chan struct{}) + if b.batchTUN != nil { + go b.batchReadLoop() + } else { + go b.readLoop() + } + egress := "auto" + if b.boundInterface != "" { + egress = b.boundInterface + monitor := b.networkManager.NetworkMonitor() + if monitor != nil { + element := monitor.RegisterCallback(func() { b.syncEgress() }) + b.unregister = func() { monitor.UnregisterCallback(element) } + } else { + b.logger.Debug("network monitor unavailable, pinned egress will not track interface changes") + } + b.syncEgress() + } else { + monitor := b.networkManager.InterfaceMonitor() + if monitor != nil { + element := monitor.RegisterCallback(func(_ *control.Interface, _ int) { b.updateClamp() }) + b.unregister = func() { monitor.UnregisterCallback(element) } + } + b.updateClamp() + } + natMode := "masquerade" + if fullConeSupported() { + natMode = "full-cone NAT" + } + b.logger.Info("bridge started at ", b.tunName, " (", natMode, ", egress ", egress, ")") + return nil +} + +func (b *backendLinux) startPlatform() error { + session, err := b.platform.CreateBridge(adapter.BridgeOptions{ + BridgeName: b.bridgeName, + MTU: bridgeTunMTU, + Inet4Port: b.inet4Port, + Inet6Port: b.inet6Port, + RuleIndex: b.ruleIndex, + RouteTable: b.routeTable, + }) + if err != nil { + return E.Cause(err, "create bridge") + } + b.session = session + b.tunName = session.Name() + if !session.Inet6Active() { + b.inet6Port = netip.Addr{} + } + tunInterface, err := tun.New(tun.Options{ + Name: b.tunName, + MTU: bridgeTunMTU, + FileDescriptor: session.FileDescriptor(), + Logger: b.logger, + }) + if err != nil { + return E.Cause(err, "create bridge tun") + } + b.tunInterface = tunInterface + err = tunInterface.Start() + if err != nil { + return E.Cause(err, "start bridge tun") + } + b.closed = make(chan struct{}) + b.readDone = make(chan struct{}) + go b.readLoop() + monitor := b.networkManager.InterfaceMonitor() + if monitor != nil { + element := monitor.RegisterCallback(func(_ *control.Interface, _ int) { b.syncSessionEgress() }) + b.unregister = func() { monitor.UnregisterCallback(element) } + } + b.syncSessionEgress() + egress := "auto" + if b.boundInterface != "" { + egress = b.boundInterface + } + b.logger.Info("bridge started at ", b.tunName, " (platform, egress ", egress, ")") + return nil +} + +func (b *backendLinux) Close() error { + b.closeOnce.Do(func() { + if b.closed != nil { + close(b.closed) + } + if b.unregister != nil { + b.unregister() + } + if b.tunInterface != nil { + b.tunInterface.Close() + } + if b.readDone != nil { + <-b.readDone + } + if b.session != nil { + _ = b.session.Close() + } else { + b.egressAccess.Lock() + if b.tunName != "" { + cleanupBridgeNetfilter(b.nftTableName) + removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port) + removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port) + } + if b.routeTable != 0 { + flushBridgeRouteTable(b.routeTable) + } + b.egressAccess.Unlock() + restoreBridgeForwarding(b.forwardingRestore) + b.forwardingRestore = nil + } + releaseBridgeIndex(b.index) + }) + return nil +} + +// Zero tells the dispatcher not to clamp the TCP MSS or fragment; the host kernel +// does both on the forwarding path instead (see setupBridgeClampRules). +func (b *backendLinux) PortMTU() uint32 { + return 0 +} + +func (b *backendLinux) WritePackets(packets [][]byte) error { + if b.batchTUN == nil { + for _, packet := range packets { + if len(packet) == 0 { + continue + } + _, err := b.tunInterface.Write(packet) + if err != nil { + return err + } + } + return nil + } + b.writeAccess.Lock() + defer b.writeAccess.Unlock() + for len(packets) > 0 { + chunk := packets + if len(chunk) > len(b.writeBuffers) { + chunk = chunk[:len(b.writeBuffers)] + } + packets = packets[len(chunk):] + batch := make([][]byte, 0, len(chunk)) + for i, packet := range chunk { + if len(packet) == 0 || len(packet) > maxPacketLength { + continue + } + buffer := b.writeBuffers[i][:b.writeHeadroom+len(packet)] + copy(buffer[b.writeHeadroom:], packet) + batch = append(batch, buffer) + } + if len(batch) == 0 { + continue + } + _, err := b.batchTUN.BatchWrite(batch, b.writeHeadroom) + if err != nil { + return err + } + } + return nil +} + +// BatchRead completes any kernel-deferred checksums while splitting GRO frames +// (virtio NEEDS_CSUM), so unlike readLoop no checksum fix is needed here. +func (b *backendLinux) batchReadLoop() { + defer close(b.readDone) + batchSize := b.batchTUN.BatchSize() + sizes := make([]int, batchSize) + batch := make([][]byte, 0, batchSize) + headroom := -1 + var buffers [][]byte + for { + b.returnAccess.Lock() + returnPaths := b.returnPaths + b.returnAccess.Unlock() + pathHeadroom := 0 + if len(returnPaths) > 0 { + pathHeadroom = returnPaths[0].ReturnHeadroom() + } + if pathHeadroom != headroom { + headroom = pathHeadroom + buffers = make([][]byte, batchSize) + for i := range buffers { + buffers[i] = make([]byte, headroom+bridgeTunMTU) + } + } + n, err := b.batchTUN.BatchRead(buffers, headroom, sizes) + if err != nil { + select { + case <-b.closed: + return + default: + } + if E.IsClosed(err) { + return + } + b.logger.Debug(E.Cause(err, "bridge tun read")) + continue + } + if n == 0 || len(returnPaths) == 0 { + continue + } + batch = batch[:0] + for i := range n { + if sizes[i] == 0 { + continue + } + batch = append(batch, buffers[i][:headroom+sizes[i]]) + } + unconsumed := batch + currentHeadroom := headroom + for _, returnPath := range returnPaths { + if len(unconsumed) == 0 { + break + } + nextHeadroom := returnPath.ReturnHeadroom() + if nextHeadroom != currentHeadroom { + rebuffered := make([][]byte, 0, len(unconsumed)) + for _, packet := range unconsumed { + payload := packet[currentHeadroom:] + buffer := make([]byte, nextHeadroom+len(payload)) + copy(buffer[nextHeadroom:], payload) + rebuffered = append(rebuffered, buffer) + } + unconsumed = rebuffered + currentHeadroom = nextHeadroom + } + unconsumed = returnPath.ReturnPackets(unconsumed) + } + } +} + +// The policy rules default to priority 100/101, ahead of sing-tun auto_route's rules, +// so forwarded packets egress the physical interface instead of looping back into +// a tun. +func (b *backendLinux) syncEgress() { + b.egressAccess.Lock() + defer b.egressAccess.Unlock() + select { + case <-b.closed: + return + default: + } + b.updateClampLocked() + flushBridgeRouteTable(b.routeTable) + link, err := netlink.LinkByName(b.boundInterface) + if err != nil { + for _, family := range activeBridgeFamilies(b.inet6Port) { + blackholeBridgeDefault(b.routeTable, family) + } + b.logger.Debug("pinned egress ", b.boundInterface, " absent, dropping forwarded traffic") + return + } + for _, family := range activeBridgeFamilies(b.inet6Port) { + b.syncEgressFamily(family, link.Attrs().Index) + } +} + +func (b *backendLinux) syncEgressFamily(family int, linkIndex int) { + connected, err := netlink.RouteListFiltered(family, &netlink.Route{ + LinkIndex: linkIndex, + Table: unix.RT_TABLE_MAIN, + }, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE) + if err == nil { + for _, route := range connected { + if route.Gw != nil || route.Dst == nil { + continue + } + pinned := route + pinned.Table = b.routeTable + pinned.ILinkIndex = 0 + _ = netlink.RouteReplace(&pinned) + } + } + resolved, err := netlink.RouteGetWithOptions(probeAddress(family), &netlink.RouteGetOptions{Oif: b.boundInterface}) + if err == nil && len(resolved) > 0 { + defaultRoute := &netlink.Route{ + LinkIndex: linkIndex, + Table: b.routeTable, + Dst: defaultDestination(family), + } + if len(resolved[0].Gw) > 0 { + defaultRoute.Gw = resolved[0].Gw + } + err = netlink.RouteReplace(defaultRoute) + if err == nil { + return + } + } + blackholeBridgeDefault(b.routeTable, family) +} + +func (b *backendLinux) updateClamp() { + b.egressAccess.Lock() + defer b.egressAccess.Unlock() + select { + case <-b.closed: + return + default: + } + b.updateClampLocked() +} + +func (b *backendLinux) updateClampLocked() { + mtu := bridgeTunMTU + egress := b.resolveEgress() + if egress != "" { + mtu = b.egressMTU(egress) + } + if mtu == b.clampMTU { + return + } + err := setupBridgeClamp(b.nftTableName, b.tunName, b.inet4Port, b.inet6Port, mtu) + if err != nil { + b.logger.Debug(E.Cause(err, "update bridge MSS clamp")) + return + } + b.clampMTU = mtu +} + +func (b *backendLinux) egressMTU(egress string) int { + iface, err := b.networkManager.InterfaceFinder().ByName(egress) + if err != nil || iface.MTU < 576 || iface.MTU > bridgeTunMTU { + return bridgeTunMTU + } + return iface.MTU +} diff --git a/protocol/bridge/backend_stub.go b/protocol/bridge/backend_stub.go new file mode 100644 index 000000000..31712dc80 --- /dev/null +++ b/protocol/bridge/backend_stub.go @@ -0,0 +1,16 @@ +//go:build !linux && !darwin + +package bridge + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) { + return nil, E.New("bridge outbound is only supported on Linux, macOS, Android with ROOT and jailbroken iOS") +} diff --git a/protocol/bridge/linkname_darwin.go b/protocol/bridge/linkname_darwin.go new file mode 100644 index 000000000..839c00fdf --- /dev/null +++ b/protocol/bridge/linkname_darwin.go @@ -0,0 +1,11 @@ +package bridge + +import ( + "unsafe" +) + +//go:linkname unixIoctlPtr golang.org/x/sys/unix.ioctlPtr +func unixIoctlPtr(fd int, request uint, arg unsafe.Pointer) error + +//go:linkname unixSysctl golang.org/x/sys/unix.sysctl +func unixSysctl(mib []int32, old *byte, oldLen *uintptr, newValue *byte, newLen uintptr) error diff --git a/protocol/bridge/netfilter_linux.go b/protocol/bridge/netfilter_linux.go new file mode 100644 index 000000000..2d1e4cace --- /dev/null +++ b/protocol/bridge/netfilter_linux.go @@ -0,0 +1,540 @@ +package bridge + +import ( + "net" + "net/netip" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + + "github.com/sagernet/netlink" + "github.com/sagernet/nftables" + "github.com/sagernet/nftables/binaryutil" + "github.com/sagernet/nftables/expr" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/sys/unix" +) + +// fullcone is an out-of-tree nftables verb (the nft_fullcone module), absent on +// stock kernels. +var ( + fullConeProbeOnce sync.Once + fullConeProbeResult bool +) + +func enableBridgeForwarding(logger logger.ContextLogger, tunName string, inet4 bool, inet6 bool) []sysctlState { + var restore []sysctlState + enable := func(path string) { + content, err := os.ReadFile(path) + if err != nil { + logger.Debug(E.Cause(err, "read ", path)) + return + } + value := strings.TrimSpace(string(content)) + if value == "1" { + return + } + err = os.WriteFile(path, []byte("1"), 0o644) + if err != nil { + logger.Debug(E.Cause(err, "enable ", path)) + return + } + restore = append(restore, sysctlState{name: path, value: value}) + } + if inet4 { + enable("/proc/sys/net/ipv4/ip_forward") + } + if inet6 { + enable("/proc/sys/net/ipv6/conf/all/forwarding") + } + _ = os.WriteFile("/proc/sys/net/ipv4/conf/"+tunName+"/rp_filter", []byte("2"), 0o644) + return restore +} + +func restoreBridgeForwarding(states []sysctlState) { + for _, state := range states { + _ = os.WriteFile(state.name, []byte(state.value), 0o644) + } +} + +var ( + nftablesProbeOnce sync.Once + nftablesMissing bool +) + +// A kernel built without CONFIG_NF_TABLES (common on pre-GKI Android) answers a +// whole nfnetlink batch with a single EOPNOTSUPP ack, while the client waits for +// one ack per batched message and blocks forever; only non-batch requests are +// answered reliably, so probe with a dump before the first batch operation. +func bridgeUseIptables() bool { + nftablesProbeOnce.Do(func() { + nft, err := nftables.New() + if err != nil { + nftablesMissing = true + return + } + _, err = nft.ListTablesOfFamily(nftables.TableFamilyINet) + nftablesMissing = err != nil + }) + return nftablesMissing +} + +func setupBridgeNetfilter(logger logger.ContextLogger, tableName string, tunName string, inet6 bool) (bool, error) { + if bridgeUseIptables() { + return setupBridgeIptables(logger, tableName, tunName, inet6) + } + err := setupBridgeNftables(tableName, tunName) + if err != nil { + return false, err + } + return inet6, nil +} + +func setupBridgeClamp(tableName string, tunName string, inet4Port netip.Addr, inet6Port netip.Addr, mtu int) error { + if bridgeUseIptables() { + return setupBridgeClampIptables(tableName, tunName, inet4Port, inet6Port, mtu) + } + return setupBridgeClampRules(tableName, tunName, inet4Port, inet6Port, mtu) +} + +func cleanupBridgeNetfilter(tableName string) { + if bridgeUseIptables() { + cleanupBridgeIptables(tableName) + return + } + cleanupBridgeNftables(tableName) +} + +// Bit 30 stays clear of Android netd's fwmark, which occupies bits 0-20 (netid, +// explicit, protected, permission, uid billing). +const bridgeIptablesMark = "0x40000000/0x40000000" + +// The libsu root process inherits a PATH without /system/bin. +func iptablesPath(binary string) string { + path, err := exec.LookPath(binary) + if err == nil { + return path + } + if runtime.GOOS == "android" { + return "/system/bin/" + binary + } + return binary +} + +func runIptables(binary string, args ...string) error { + output, err := exec.Command(iptablesPath(binary), args...).CombinedOutput() + if err != nil { + return E.Cause(err, binary, " ", strings.Join(args, " "), ": ", strings.TrimSpace(string(output))) + } + return nil +} + +// iptables refuses input interface matches in nat POSTROUTING, so the mangle +// FORWARD chain marks packets entering from the bridge tun and the nat chain +// masquerades by mark. +func setupBridgeIptables(logger logger.ContextLogger, tableName string, tunName string, inet6 bool) (bool, error) { + cleanupBridgeIptables(tableName) + err := setupBridgeIptablesFamily("iptables", tableName, tunName) + if err != nil { + cleanupBridgeIptablesFamily("iptables", tableName) + return false, err + } + if !inet6 { + return false, nil + } + err = setupBridgeIptablesFamily("ip6tables", tableName, tunName) + if err != nil { + cleanupBridgeIptablesFamily("ip6tables", tableName) + logger.Debug(E.Cause(err, "IPv6 NAT unavailable, disabling IPv6 forwarding")) + return false, nil + } + return true, nil +} + +func setupBridgeIptablesFamily(binary string, tableName string, tunName string) error { + err := runIptables(binary, "-t", "nat", "-N", tableName) + if err != nil { + return err + } + err = runIptables(binary, "-t", "nat", "-A", tableName, "-m", "mark", "--mark", bridgeIptablesMark, "-j", "MASQUERADE") + if err != nil { + return err + } + err = runIptables(binary, "-t", "nat", "-I", "POSTROUTING", "-j", tableName) + if err != nil { + return err + } + err = runIptables(binary, "-t", "mangle", "-N", tableName) + if err != nil { + return err + } + err = runIptables(binary, "-t", "mangle", "-A", tableName, "-i", tunName, "-j", "MARK", "--set-xmark", bridgeIptablesMark) + if err != nil { + return err + } + err = runIptables(binary, "-t", "mangle", "-I", "FORWARD", "-j", tableName) + if err != nil { + return err + } + return setupBridgeFilterAcceptFamily(binary, tableName, tunName) +} + +// netd installs an unconditional DROP in the filter FORWARD chain +// (tetherctrl_FORWARD). +func setupBridgeFilterAcceptFamily(binary string, tableName string, tunName string) error { + err := runIptables(binary, "-t", "filter", "-N", tableName) + if err != nil { + return err + } + err = runIptables(binary, "-t", "filter", "-A", tableName, "-i", tunName, "-j", "ACCEPT") + if err != nil { + return err + } + err = runIptables(binary, "-t", "filter", "-A", tableName, "-o", tunName, "-j", "ACCEPT") + if err != nil { + return err + } + return runIptables(binary, "-t", "filter", "-I", "FORWARD", "-j", tableName) +} + +func setupBridgeClampIptables(tableName string, tunName string, inet4Port netip.Addr, inet6Port netip.Addr, mtu int) error { + families := []struct { + binary string + port netip.Addr + headerSize int + }{ + {"iptables", inet4Port, 40}, + {"ip6tables", inet6Port, 60}, + } + for _, family := range families { + if !family.port.IsValid() { + continue + } + err := runIptables(family.binary, "-t", "mangle", "-F", tableName) + if err != nil { + return err + } + err = runIptables(family.binary, "-t", "mangle", "-A", tableName, "-i", tunName, "-j", "MARK", "--set-xmark", bridgeIptablesMark) + if err != nil { + return err + } + err = runIptables(family.binary, "-t", "mangle", "-A", tableName, "-i", tunName, + "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", + "-j", "TCPMSS", "--set-mss", strconv.Itoa(mtu-family.headerSize)) + if err != nil { + return err + } + } + return nil +} + +func cleanupBridgeIptables(tableName string) { + cleanupBridgeIptablesFamily("iptables", tableName) + cleanupBridgeIptablesFamily("ip6tables", tableName) +} + +func cleanupBridgeIptablesFamily(binary string, tableName string) { + cleanupBridgeIptablesTable(binary, "nat", "POSTROUTING", tableName) + cleanupBridgeIptablesTable(binary, "mangle", "FORWARD", tableName) + cleanupBridgeIptablesTable(binary, "filter", "FORWARD", tableName) +} + +func cleanupBridgeIptablesTable(binary string, table string, hookChain string, tableName string) { + path := iptablesPath(binary) + _ = exec.Command(path, "-t", table, "-D", hookChain, "-j", tableName).Run() + _ = exec.Command(path, "-t", table, "-F", tableName).Run() + _ = exec.Command(path, "-t", table, "-X", tableName).Run() +} + +func setupBridgeFamily(tunName string, ruleIndex int, routeTable int, family int, port netip.Addr) error { + if !port.IsValid() { + return nil + } + link, err := netlink.LinkByName(tunName) + if err != nil { + return err + } + err = netlink.RouteReplace(bridgeFamilyRoute(link.Attrs().Index, family, port)) + if err != nil { + return E.Cause(err, "add route") + } + for _, rule := range bridgeFamilyRules(tunName, ruleIndex, routeTable, family, port) { + _ = netlink.RuleDel(rule) + err = netlink.RuleAdd(rule) + if err != nil { + return E.Cause(err, "add rule") + } + } + return nil +} + +func removeBridgeFamily(tunName string, ruleIndex int, routeTable int, family int, port netip.Addr) { + if !port.IsValid() { + return + } + link, err := netlink.LinkByName(tunName) + if err == nil { + _ = netlink.RouteDel(bridgeFamilyRoute(link.Attrs().Index, family, port)) + } + for _, rule := range bridgeFamilyRules(tunName, ruleIndex, routeTable, family, port) { + _ = netlink.RuleDel(rule) + } +} + +func bridgeFamilyRoute(linkIndex int, family int, port netip.Addr) *netlink.Route { + bits := port.BitLen() + route := &netlink.Route{ + LinkIndex: linkIndex, + Dst: &net.IPNet{IP: port.AsSlice(), Mask: net.CIDRMask(bits, bits)}, + Table: unix.RT_TABLE_MAIN, + } + if family == unix.AF_INET { + route.Scope = netlink.Scope(unix.RT_SCOPE_LINK) + } + return route +} + +func bridgeFamilyRules(tunName string, ruleIndex int, routeTable int, family int, port netip.Addr) []*netlink.Rule { + forwardTable := unix.RT_TABLE_MAIN + if routeTable != 0 { + forwardTable = routeTable + } + + iifRule := netlink.NewRule() + iifRule.Priority = ruleIndex + iifRule.IifName = tunName + iifRule.Table = forwardTable + iifRule.Family = family + + toRule := netlink.NewRule() + toRule.Priority = ruleIndex + 1 + toRule.Dst = netip.PrefixFrom(port, port.BitLen()) + toRule.Table = unix.RT_TABLE_MAIN + toRule.Family = family + + return []*netlink.Rule{iifRule, toRule} +} + +func flushBridgeRouteTable(routeTable int) { + for _, family := range []int{unix.AF_INET, unix.AF_INET6} { + routes, err := netlink.RouteListFiltered(family, &netlink.Route{Table: routeTable}, netlink.RT_FILTER_TABLE) + if err != nil { + continue + } + for _, route := range routes { + toDelete := route + _ = netlink.RouteDel(&toDelete) + } + } +} + +func blackholeBridgeDefault(routeTable int, family int) { + _ = netlink.RouteReplace(&netlink.Route{ + Table: routeTable, + Family: family, + Type: unix.RTN_BLACKHOLE, + Dst: defaultDestination(family), + }) +} + +func activeBridgeFamilies(inet6Port netip.Addr) []int { + families := []int{unix.AF_INET} + if inet6Port.IsValid() { + families = append(families, unix.AF_INET6) + } + return families +} + +func probeAddress(family int) net.IP { + if family == unix.AF_INET6 { + return net.ParseIP("2000::") + } + return net.IPv4(1, 1, 1, 1) +} + +func defaultDestination(family int) *net.IPNet { + if family == unix.AF_INET6 { + return &net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)} + } + return &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)} +} + +func setupBridgeNftables(tableName string, tunName string) error { + cleanupBridgeNftables(tableName) + nft, err := nftables.New() + if err != nil { + return err + } + table := nft.AddTable(&nftables.Table{ + Family: nftables.TableFamilyINet, + Name: tableName, + }) + chain := nft.AddChain(&nftables.Chain{ + Name: "postrouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityNATSource, + }) + // The nft_fullcone verb, like masquerade, sources from the routing-chosen egress + // interface. + var sourceNat expr.Any = &expr.Masq{} + if fullConeSupported() { + sourceNat = &expr.FullCone{} + } + nft.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nftIfname(tunName)}, + sourceNat, + }, + }) + nft.AddChain(&nftables.Chain{ + Name: "forward", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityMangle, + }) + return nft.Flush() +} + +// nft_exthdr writes the MSS option unconditionally — unlike pf's max-mss or +// xt_TCPMSS it would also raise a smaller advertised MSS — so the rule matches +// only when the advertised MSS exceeds the clamp value. +func setupBridgeClampRules(tableName string, tunName string, inet4Port netip.Addr, inet6Port netip.Addr, mtu int) error { + nft, err := nftables.New() + if err != nil { + return err + } + table := &nftables.Table{ + Family: nftables.TableFamilyINet, + Name: tableName, + } + chain := &nftables.Chain{ + Name: "forward", + Table: table, + } + nft.FlushChain(chain) + families := []struct { + protocol byte + port netip.Addr + headerSize int + }{ + {unix.NFPROTO_IPV4, inet4Port, 40}, + {unix.NFPROTO_IPV6, inet6Port, 60}, + } + for _, family := range families { + if !family.port.IsValid() { + continue + } + clamp := binaryutil.BigEndian.PutUint16(uint16(mtu - family.headerSize)) + nft.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{family.protocol}}, + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nftIfname(tunName)}, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_TCP}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 13, Len: 1}, + &expr.Bitwise{SourceRegister: 1, DestRegister: 1, Len: 1, Mask: []byte{0x02}, Xor: []byte{0x00}}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{0x02}}, + &expr.Exthdr{DestRegister: 1, Type: 2, Offset: 2, Len: 2, Op: expr.ExthdrOpTcpopt}, + &expr.Cmp{Op: expr.CmpOpGt, Register: 1, Data: clamp}, + &expr.Immediate{Register: 1, Data: clamp}, + &expr.Exthdr{SourceRegister: 1, Type: 2, Offset: 2, Len: 2, Op: expr.ExthdrOpTcpopt}, + }, + }) + } + return nft.Flush() +} + +func cleanupBridgeNftables(tableName string) { + nft, err := nftables.New() + if err != nil { + return + } + table, err := nft.ListTableOfFamily(tableName, nftables.TableFamilyINet) + if err != nil || table == nil { + return + } + nft.DelTable(table) + _ = nft.Flush() +} + +func fullConeSupported() bool { + if runtime.GOOS == "android" { + return false + } + if bridgeUseIptables() { + return false + } + fullConeProbeOnce.Do(func() { + fullConeProbeResult = probeFullCone() + }) + return fullConeProbeResult +} + +const fullConeProbeTable = "sing-box-fullcone-probe" + +// The kernel loads and validates the expression's module when the batch commits: +// a clean flush means the verb is available, a rejected one rolls back atomically. +func probeFullCone() bool { + deleteFullConeProbe() + nft, err := nftables.New() + if err != nil { + return false + } + table := nft.AddTable(&nftables.Table{ + Family: nftables.TableFamilyINet, + Name: fullConeProbeTable, + }) + chain := nft.AddChain(&nftables.Chain{ + Name: "postrouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityNATSource, + }) + nft.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nftIfname("sing-box-probe0")}, + &expr.FullCone{}, + }, + }) + supported := nft.Flush() == nil + deleteFullConeProbe() + return supported +} + +func deleteFullConeProbe() { + nft, err := nftables.New() + if err != nil { + return + } + table, err := nft.ListTableOfFamily(fullConeProbeTable, nftables.TableFamilyINet) + if err != nil || table == nil { + return + } + nft.DelTable(table) + _ = nft.Flush() +} + +func nftIfname(name string) []byte { + padded := make([]byte, 16) + copy(padded, name) + return padded +} diff --git a/protocol/bridge/outbound.go b/protocol/bridge/outbound.go new file mode 100644 index 000000000..086923aca --- /dev/null +++ b/protocol/bridge/outbound.go @@ -0,0 +1,90 @@ +package bridge + +import ( + "context" + "net" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.BridgeOutboundOptions](registry, C.TypeBridge, NewOutbound) +} + +var ( + _ adapter.Outbound = (*Outbound)(nil) + _ adapter.FlowOutbound = (*Outbound)(nil) + _ adapter.Lifecycle = (*Outbound)(nil) +) + +type Backend interface { + adapter.Lifecycle + tun.Port +} + +type Outbound struct { + outbound.Adapter + backend Backend +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BridgeOutboundOptions) (adapter.Outbound, error) { + networkManager := service.FromContext[adapter.NetworkManager](ctx) + outboundBackend, err := newBackend(ctx, logger, networkManager, tag, options) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapter(C.TypeBridge, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil), + backend: outboundBackend, + }, nil +} + +func (o *Outbound) Start(stage adapter.StartStage) error { + return o.backend.Start(stage) +} + +func (o *Outbound) Close() error { + return o.backend.Close() +} + +func (o *Outbound) SupportsFlow(network string) bool { + return true +} + +func (o *Outbound) PortAddresses() (netip.Addr, netip.Addr) { + return o.backend.PortAddresses() +} + +func (o *Outbound) PortMTU() uint32 { + return o.backend.PortMTU() +} + +func (o *Outbound) AttachReturn(returnPath tun.Return) error { + return o.backend.AttachReturn(returnPath) +} + +func (o *Outbound) DetachReturn(returnPath tun.Return) error { + return o.backend.DetachReturn(returnPath) +} + +func (o *Outbound) WritePackets(packets [][]byte) error { + return o.backend.WritePackets(packets) +} + +func (o *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return nil, E.New("Only L3 traffic is supported by bridge") +} + +func (o *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, E.New("Only L3 traffic is supported by bridge") +} diff --git a/protocol/bridge/packet.go b/protocol/bridge/packet.go new file mode 100644 index 000000000..5bbf6a045 --- /dev/null +++ b/protocol/bridge/packet.go @@ -0,0 +1,121 @@ +//go:build linux || darwin + +package bridge + +import ( + "net/netip" + "sync" + + "github.com/sagernet/sing-tun/gtcpip" + "github.com/sagernet/sing-tun/gtcpip/checksum" + "github.com/sagernet/sing-tun/gtcpip/header" + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + bridgeTunMTU = 1500 + maxPacketLength = 0xffff + bridgeMaxInstances = 254 +) + +var ( + bridgeInet4Base = netip.MustParseAddr("192.0.2.1") + bridgeInet6Base = netip.MustParseAddr("2001:db8::1") + + bridgeIndexAccess sync.Mutex + bridgeIndexInUse [bridgeMaxInstances]bool +) + +func allocateBridgeIndex() (uint32, error) { + bridgeIndexAccess.Lock() + defer bridgeIndexAccess.Unlock() + for index := range bridgeMaxInstances { + if !bridgeIndexInUse[index] { + bridgeIndexInUse[index] = true + return uint32(index), nil + } + } + return 0, E.New("too many bridge outbounds: limit is ", bridgeMaxInstances) +} + +func releaseBridgeIndex(index uint32) { + bridgeIndexAccess.Lock() + defer bridgeIndexAccess.Unlock() + bridgeIndexInUse[index] = false +} + +func addressAt(base netip.Addr, offset uint32) netip.Addr { + addr := base + for range offset { + addr = addr.Next() + } + return addr +} + +func fixReturnChecksum(packet []byte) { + switch header.IPVersion(packet) { + case header.IPv4Version: + if len(packet) < header.IPv4MinimumSize { + return + } + ipHdr := header.IPv4(packet) + if !ipHdr.IsValid(len(packet)) { + return + } + if ipHdr.Flags()&header.IPv4FlagMoreFragments != 0 || ipHdr.FragmentOffset() != 0 { + return + } + ipHdr.SetChecksum(0) + ipHdr.SetChecksum(^ipHdr.CalculateChecksum()) + recomputeTransportChecksum(ipHdr.TransportProtocol(), ipHdr.Payload(), ipHdr.SourceAddressSlice(), ipHdr.DestinationAddressSlice()) + case header.IPv6Version: + if len(packet) < header.IPv6MinimumSize { + return + } + ipHdr := header.IPv6(packet) + recomputeTransportChecksum(ipHdr.TransportProtocol(), ipHdr.Payload(), ipHdr.SourceAddressSlice(), ipHdr.DestinationAddressSlice()) + } +} + +func recomputeTransportChecksum(protocol tcpip.TransportProtocolNumber, transport []byte, source []byte, destination []byte) { + switch protocol { + case header.TCPProtocolNumber: + if len(transport) < header.TCPMinimumSize { + return + } + tcpHdr := header.TCP(transport) + tcpHdr.SetChecksum(0) + payloadChecksum := checksum.Checksum(tcpHdr.Payload(), 0) + pseudoChecksum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, source, destination, uint16(len(transport))) + tcpHdr.SetChecksum(^tcpHdr.CalculateChecksum(checksum.Combine(pseudoChecksum, payloadChecksum))) + case header.UDPProtocolNumber: + if len(transport) < header.UDPMinimumSize { + return + } + udpHdr := header.UDP(transport) + udpHdr.SetChecksum(0) + payloadChecksum := checksum.Checksum(udpHdr.Payload(), 0) + pseudoChecksum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, source, destination, udpHdr.Length()) + udpChecksum := ^udpHdr.CalculateChecksum(checksum.Combine(pseudoChecksum, payloadChecksum)) + if udpChecksum == 0 { + udpChecksum = 0xffff + } + udpHdr.SetChecksum(udpChecksum) + case header.ICMPv4ProtocolNumber: + if len(transport) < header.ICMPv4MinimumSize { + return + } + icmpHdr := header.ICMPv4(transport) + icmpHdr.SetChecksum(header.ICMPv4Checksum(icmpHdr, 0)) + case header.ICMPv6ProtocolNumber: + if len(transport) < header.ICMPv6MinimumSize { + return + } + icmpHdr := header.ICMPv6(transport) + icmpHdr.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{ + Header: icmpHdr, + Src: source, + Dst: destination, + })) + } +} diff --git a/protocol/bridge/pf_darwin.go b/protocol/bridge/pf_darwin.go new file mode 100644 index 000000000..3d44366e4 --- /dev/null +++ b/protocol/bridge/pf_darwin.go @@ -0,0 +1,379 @@ +package bridge + +import ( + "net" + "net/netip" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/unix" +) + +// Layouts and values mirror bsd/net/pfvar.h from xnu, which the SDKs do not +// ship; unchanged from xnu-4570.1.46 (macOS 10.13) through xnu-12377.1.9. + +const ( + pfRulesetScrub = 0 + pfRulesetFilter = 1 + pfRulesetNat = 2 + + pfActionPass = 0 + pfActionScrub = 2 + pfActionNat = 4 + + pfDirectionIn = 1 + + pfAddrTypeAddressMask = 0 + pfAddrTypeDynamicInterface = 2 + + pfRouteActionRouteTo = 2 + + pfStateNormal = 1 + + pfNatProxyPortLow = 50001 + pfNatProxyPortHigh = 65535 +) + +type pfAddr [16]byte + +type pfAddrWrap struct { + Addr pfAddr + Mask pfAddr + _ uint64 + Type uint8 + IFlags uint8 + _ [6]byte +} + +type pfRuleAddr struct { + Addr pfAddrWrap + _ [8]byte + Neg uint8 + _ [7]byte +} + +type pfPool struct { + _ [2]uint64 + _ uint64 + _ [16]byte + _ pfAddr + TableIndex int32 + ProxyPort [2]uint16 + PortOp uint8 + Opts uint8 + AF uint8 + _ [5]byte +} + +type pfRuleUserGroup struct { + Range [2]uint32 + Op uint8 + _ [3]byte +} + +type pfRule struct { + Src pfRuleAddr + Dst pfRuleAddr + _ [8]uint64 + Label [64]byte + IfName [16]byte + QName [64]byte + PQName [64]byte + TagName [64]byte + MatchTagName [64]byte + OverloadTable [32]byte + _ [2]uint64 + RPool pfPool + Evaluations uint64 + Packets [2]uint64 + Bytes [2]uint64 + Ticket uint64 + Owner [64]byte + Priority uint32 + _ uint32 + _ [3]uint64 + OSFingerprint uint32 + RouteTableID uint32 + Timeout [26]uint32 + States uint32 + MaxStates uint32 + SrcNodes uint32 + MaxSrcNodes uint32 + MaxSrcStates uint32 + MaxSrcConn uint32 + MaxSrcConnRate [2]uint32 + QID uint32 + PQID uint32 + RouteListID uint32 + Nr uint32 + Prob uint32 + CreatorUID uint32 + CreatorPID uint32 + ReturnICMP uint16 + ReturnICMP6 uint16 + MaxMSS uint16 + Tag uint16 + MatchTag uint16 + _ uint16 + UID pfRuleUserGroup + GID pfRuleUserGroup + RuleFlag uint32 + Action uint8 + Direction uint8 + Log uint8 + LogIf uint8 + Quick uint8 + IfNot uint8 + MatchTagNot uint8 + NatPass uint8 + KeepState uint8 + AF uint8 + Proto uint8 + Type uint8 + Code uint8 + Flags uint8 + FlagSet uint8 + MinTTL uint8 + AllowOpts uint8 + RouteAction uint8 + ReturnTTL uint8 + TOS uint8 + AnchorRelative uint8 + AnchorWildcard uint8 + Flush uint8 + ProtoVariant uint8 + ExtFilter uint8 + ExtMap uint8 + _ uint16 + DummynetPipe uint32 + DummynetType uint32 +} + +type pfPoolAddr struct { + Addr pfAddrWrap + _ [2]uint64 + IfName [16]byte + _ uint64 +} + +type pfiocRule struct { + Action uint32 + Ticket uint32 + PoolTicket uint32 + Nr uint32 + Anchor [1024]byte + AnchorCall [1024]byte + Rule pfRule +} + +type pfiocPoolAddr struct { + Action uint32 + Ticket uint32 + Nr uint32 + RNum uint32 + RAction uint8 + RLast uint8 + AF uint8 + Anchor [1024]byte + _ [5]byte + Addr pfPoolAddr +} + +type pfiocTransElement struct { + RulesetIndex int32 + Anchor [1024]byte + Ticket uint32 +} + +type pfiocTrans struct { + Size int32 + ElementSize int32 + Array *pfiocTransElement +} + +type pfiocRemoveToken struct { + Token uint64 + RefCount uint64 +} + +const ( + iocParamMask = 0x1fff + iocOut = 0x40000000 + iocIn = 0x80000000 + iocInOut = iocIn | iocOut +) + +const ( + diocAddRule = iocInOut | (uint(unsafe.Sizeof(pfiocRule{}))&iocParamMask)<<16 | 'D'<<8 | 4 + diocStartRef = iocOut | 8<<16 | 'D'<<8 | 8 + diocStopRef = iocInOut | (uint(unsafe.Sizeof(pfiocRemoveToken{}))&iocParamMask)<<16 | 'D'<<8 | 9 + diocBeginAddrs = iocInOut | (uint(unsafe.Sizeof(pfiocPoolAddr{}))&iocParamMask)<<16 | 'D'<<8 | 51 + diocAddAddr = iocInOut | (uint(unsafe.Sizeof(pfiocPoolAddr{}))&iocParamMask)<<16 | 'D'<<8 | 52 + diocXBegin = iocInOut | (uint(unsafe.Sizeof(pfiocTrans{}))&iocParamMask)<<16 | 'D'<<8 | 81 + diocXCommit = iocInOut | (uint(unsafe.Sizeof(pfiocTrans{}))&iocParamMask)<<16 | 'D'<<8 | 82 + diocXRollback = iocInOut | (uint(unsafe.Sizeof(pfiocTrans{}))&iocParamMask)<<16 | 'D'<<8 | 83 +) + +type pfAnchorRule struct { + RulesetIndex int32 + Rule pfRule + Pool pfPoolAddr +} + +type pfDevice struct { + fd int +} + +func openPfDevice() (*pfDevice, error) { + fd, err := unix.Open("/dev/pf", unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + return nil, E.Cause(err, "open /dev/pf") + } + return &pfDevice{fd: fd}, nil +} + +func (d *pfDevice) Close() error { + return unix.Close(d.fd) +} + +func (d *pfDevice) ioctl(request uint, pointer unsafe.Pointer) error { + return unixIoctlPtr(d.fd, request, pointer) +} + +func (d *pfDevice) StartReference() (uint64, error) { + var token uint64 + err := d.ioctl(uint(diocStartRef), unsafe.Pointer(&token)) + if err != nil { + return 0, E.Cause(err, "DIOCSTARTREF") + } + return token, nil +} + +func (d *pfDevice) StopReference(token uint64) error { + remove := pfiocRemoveToken{Token: token} + err := d.ioctl(uint(diocStopRef), unsafe.Pointer(&remove)) + if err != nil { + return E.Cause(err, "DIOCSTOPREF") + } + return nil +} + +// LoadAnchor atomically replaces the anchor's scrub, nat and filter rulesets; +// empty rules flush the anchor. +func (d *pfDevice) LoadAnchor(anchor string, rules []pfAnchorRule) error { + elements := [3]pfiocTransElement{ + {RulesetIndex: pfRulesetScrub}, + {RulesetIndex: pfRulesetNat}, + {RulesetIndex: pfRulesetFilter}, + } + for i := range elements { + copy(elements[i].Anchor[:], anchor) + } + trans := pfiocTrans{ + Size: int32(len(elements)), + ElementSize: int32(unsafe.Sizeof(pfiocTransElement{})), + Array: &elements[0], + } + err := d.ioctl(uint(diocXBegin), unsafe.Pointer(&trans)) + if err != nil { + return E.Cause(err, "DIOCXBEGIN") + } + for _, rule := range rules { + err = d.addRule(anchor, &elements, rule) + if err != nil { + _ = d.ioctl(uint(diocXRollback), unsafe.Pointer(&trans)) + return err + } + } + err = d.ioctl(uint(diocXCommit), unsafe.Pointer(&trans)) + if err != nil { + return E.Cause(err, "DIOCXCOMMIT") + } + return nil +} + +func (d *pfDevice) addRule(anchor string, elements *[3]pfiocTransElement, rule pfAnchorRule) error { + var pool pfiocPoolAddr + err := d.ioctl(uint(diocBeginAddrs), unsafe.Pointer(&pool)) + if err != nil { + return E.Cause(err, "DIOCBEGINADDRS") + } + if rule.Pool != (pfPoolAddr{}) { + pool.Addr = rule.Pool + pool.AF = rule.Rule.AF + err = d.ioctl(uint(diocAddAddr), unsafe.Pointer(&pool)) + if err != nil { + return E.Cause(err, "DIOCADDADDR") + } + } + var ticket uint32 + for _, element := range elements { + if element.RulesetIndex == rule.RulesetIndex { + ticket = element.Ticket + } + } + request := pfiocRule{ + Ticket: ticket, + PoolTicket: pool.Ticket, + Rule: rule.Rule, + } + copy(request.Anchor[:], anchor) + err = d.ioctl(uint(diocAddRule), unsafe.Pointer(&request)) + if err != nil { + return E.Cause(err, "DIOCADDRULE") + } + return nil +} + +func pfAddrOf(address netip.Addr) (result pfAddr) { + if address.Is4() { + addr4 := address.As4() + copy(result[:], addr4[:]) + } else { + addr16 := address.As16() + copy(result[:], addr16[:]) + } + return +} + +func pfMaskOf(bits int, is4 bool) (result pfAddr) { + totalBits := 128 + if is4 { + totalBits = 32 + } + copy(result[:], net.CIDRMask(bits, totalBits)) + return +} + +func pfHostAddress(address netip.Addr) pfAddrWrap { + return pfPrefixAddress(netip.PrefixFrom(address, address.BitLen())) +} + +func pfPrefixAddress(prefix netip.Prefix) pfAddrWrap { + return pfAddrWrap{ + Type: pfAddrTypeAddressMask, + Addr: pfAddrOf(prefix.Addr()), + Mask: pfMaskOf(prefix.Bits(), prefix.Addr().Is4()), + } +} + +func pfDynamicInterfaceAddress(interfaceName string, is4 bool) pfAddrWrap { + wrap := pfAddrWrap{ + Type: pfAddrTypeDynamicInterface, + } + if is4 { + wrap.Mask = pfMaskOf(32, true) + } else { + wrap.Mask = pfMaskOf(128, false) + } + copy(wrap.Addr[:], interfaceName) + return wrap +} + +func pfFamily(is4 bool) uint8 { + if is4 { + return unix.AF_INET + } + return unix.AF_INET6 +} diff --git a/protocol/bridge/route_darwin.go b/protocol/bridge/route_darwin.go new file mode 100644 index 000000000..2f703358f --- /dev/null +++ b/protocol/bridge/route_darwin.go @@ -0,0 +1,223 @@ +package bridge + +import ( + "net" + "net/netip" + "os" + "sync/atomic" + "syscall" + "unsafe" + + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +var routeMessageSeq atomic.Int32 + +func interfaceGateway(interfaceIndex int, is4 bool) netip.Addr { + socketFd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + return netip.Addr{} + } + defer unix.Close(socketFd) + _ = unix.SetsockoptTimeval(socketFd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 1}) + var destination route.Addr + if is4 { + destination = &route.Inet4Addr{} + } else { + destination = &route.Inet6Addr{} + } + seq := int(routeMessageSeq.Add(1)) + message := route.RouteMessage{ + Type: unix.RTM_GET, + Version: unix.RTM_VERSION, + Flags: unix.RTF_IFSCOPE, + Index: interfaceIndex, + ID: uintptr(os.Getpid()), + Seq: seq, + Addrs: []route.Addr{syscall.RTAX_DST: destination}, + } + request, err := message.Marshal() + if err != nil { + return netip.Addr{} + } + _, err = unix.Write(socketFd, request) + if err != nil { + return netip.Addr{} + } + buffer := make([]byte, 2048) + for { + n, err := unix.Read(socketFd, buffer) + if err != nil { + return netip.Addr{} + } + messages, err := route.ParseRIB(route.RIBTypeRoute, buffer[:n]) + if err != nil { + continue + } + for _, routeMessage := range messages { + reply, isRoute := routeMessage.(*route.RouteMessage) + if !isRoute || reply.Seq != seq || reply.ID != uintptr(os.Getpid()) { + continue + } + if reply.Err != nil || reply.Flags&unix.RTF_GATEWAY == 0 || len(reply.Addrs) <= syscall.RTAX_GATEWAY { + return netip.Addr{} + } + switch gateway := reply.Addrs[syscall.RTAX_GATEWAY].(type) { + case *route.Inet4Addr: + return netip.AddrFrom4(gateway.IP) + case *route.Inet6Addr: + return netip.AddrFrom16(gateway.IP) + default: + return netip.Addr{} + } + } + } +} + +func addInterfaceHostRoute(destination netip.Addr, interfaceName string) error { + tunInterface, err := net.InterfaceByName(interfaceName) + if err != nil { + return err + } + var destinationAddr, maskAddr route.Addr + if destination.Is4() { + destinationAddr = &route.Inet4Addr{IP: destination.As4()} + maskAddr = &route.Inet4Addr{IP: [4]byte{255, 255, 255, 255}} + } else { + destinationAddr = &route.Inet6Addr{IP: destination.As16()} + maskAddr = &route.Inet6Addr{IP: [16]byte{ + 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, + }} + } + message := route.RouteMessage{ + Type: unix.RTM_ADD, + Version: unix.RTM_VERSION, + Flags: unix.RTF_UP | unix.RTF_HOST | unix.RTF_STATIC, + Seq: int(routeMessageSeq.Add(1)), + Addrs: []route.Addr{ + syscall.RTAX_DST: destinationAddr, + syscall.RTAX_GATEWAY: &route.LinkAddr{Index: tunInterface.Index}, + syscall.RTAX_NETMASK: maskAddr, + }, + } + request, err := message.Marshal() + if err != nil { + return err + } + socketFd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + return err + } + defer unix.Close(socketFd) + _, err = unix.Write(socketFd, request) + if err != nil && err != unix.EEXIST { + return E.Cause(err, "RTM_ADD") + } + return nil +} + +type ifAliasRequest struct { + Name [unix.IFNAMSIZ]byte + Addr unix.RawSockaddrInet4 + DstAddr unix.RawSockaddrInet4 + Mask unix.RawSockaddrInet4 +} + +type inet6AddrLifetime struct { + Expire float64 + Preferred float64 + Vltime uint32 + Pltime uint32 +} + +type ifAliasRequest6 struct { + Name [unix.IFNAMSIZ]byte + Addr unix.RawSockaddrInet6 + DstAddr unix.RawSockaddrInet6 + Mask unix.RawSockaddrInet6 + Flags uint32 + Lifetime inet6AddrLifetime +} + +func assignPointToPointAddress(interfaceName string, local netip.Addr, peer netip.Addr) error { + if local.Is4() { + request := ifAliasRequest{ + Addr: unix.RawSockaddrInet4{ + Len: unix.SizeofSockaddrInet4, + Family: unix.AF_INET, + Addr: local.As4(), + }, + DstAddr: unix.RawSockaddrInet4{ + Len: unix.SizeofSockaddrInet4, + Family: unix.AF_INET, + Addr: peer.As4(), + }, + Mask: unix.RawSockaddrInet4{ + Len: unix.SizeofSockaddrInet4, + Family: unix.AF_INET, + Addr: [4]byte{255, 255, 255, 255}, + }, + } + copy(request.Name[:], interfaceName) + return interfaceIoctl(unix.AF_INET, uint(unix.SIOCAIFADDR), unsafe.Pointer(&request)) + } + request := ifAliasRequest6{ + Addr: unix.RawSockaddrInet6{ + Len: unix.SizeofSockaddrInet6, + Family: unix.AF_INET6, + Addr: local.As16(), + }, + DstAddr: unix.RawSockaddrInet6{ + Len: unix.SizeofSockaddrInet6, + Family: unix.AF_INET6, + Addr: peer.As16(), + }, + Mask: unix.RawSockaddrInet6{ + Len: unix.SizeofSockaddrInet6, + Family: unix.AF_INET6, + Addr: [16]byte{ + 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, + }, + }, + Flags: tun.IN6_IFF_NODAD | tun.IN6_IFF_SECURED, + Lifetime: inet6AddrLifetime{ + Vltime: tun.ND6_INFINITE_LIFETIME, + Pltime: tun.ND6_INFINITE_LIFETIME, + }, + } + copy(request.Name[:], interfaceName) + return interfaceIoctl(unix.AF_INET6, tun.SIOCAIFADDR_IN6, unsafe.Pointer(&request)) +} + +func interfaceIoctl(family int, request uint, pointer unsafe.Pointer) error { + socketFd, err := unix.Socket(family, unix.SOCK_DGRAM, 0) + if err != nil { + return err + } + defer unix.Close(socketFd) + return unixIoctlPtr(socketFd, request, pointer) +} + +var forwardingMibs = map[string][]int32{ + // CTL_NET, PF_INET, IPPROTO_IP, IPCTL_FORWARDING (netinet/in.h) + "net.inet.ip.forwarding": {syscall.CTL_NET, unix.AF_INET, 0, 1}, + // CTL_NET, PF_INET6, IPPROTO_IPV6, IPV6CTL_FORWARDING (netinet6/in6.h) + "net.inet6.ip6.forwarding": {syscall.CTL_NET, unix.AF_INET6, unix.IPPROTO_IPV6, 1}, +} + +func getSysctlInt32(mib []int32) (int32, error) { + var value int32 + valueLen := unsafe.Sizeof(value) + err := unixSysctl(mib, (*byte)(unsafe.Pointer(&value)), &valueLen, nil, 0) + return value, err +} + +func setSysctlInt32(mib []int32, value int32) error { + return unixSysctl(mib, nil, nil, (*byte)(unsafe.Pointer(&value)), unsafe.Sizeof(value)) +} diff --git a/protocol/bridge/rules_darwin.go b/protocol/bridge/rules_darwin.go new file mode 100644 index 000000000..cfb276a60 --- /dev/null +++ b/protocol/bridge/rules_darwin.go @@ -0,0 +1,285 @@ +package bridge + +import ( + "net" + "net/netip" + "slices" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/sys/unix" +) + +func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egress string, boundInterface string, inet4Port netip.Addr, inet6Port netip.Addr) []pfAnchorRule { + egressInterface, err := net.InterfaceByName(egress) + if err != nil { + return nil + } + mtu := egressInterface.MTU + if mtu < 576 || mtu > bridgeTunMTU { + mtu = bridgeTunMTU + } + localPrefixes, inet4Interfaces, inet6Interfaces := collectLocalSegments(egress, boundInterface, inet4Port.IsValid(), inet6Port.IsValid()) + var rules []pfAnchorRule + if inet4Port.IsValid() { + rules = append(rules, pfScrubRule(egress, inet4Port, uint16(mtu-40))) + } + if inet6Port.IsValid() { + rules = append(rules, pfScrubRule(egress, inet6Port, uint16(mtu-60))) + } + if inet4Port.IsValid() { + rules = append(rules, pfNatRule(egress, inet4Port)) + for _, name := range inet4Interfaces { + rules = append(rules, pfNatRule(name, inet4Port)) + } + } + if inet6Port.IsValid() { + rules = append(rules, pfNatRule(egress, inet6Port)) + for _, name := range inet6Interfaces { + rules = append(rules, pfNatRule(name, inet6Port)) + } + } + // pf evaluates translation on the interface the routing table picks, and + // route-to on an out rule does not re-run it on the new interface: when + // another tun holds the default route the nat-on-egress rule never matches. + // route-to on the in side redirects before routing, so the packet actually + // leaves via the egress and the nat rule applies there. + if inet4Port.IsValid() { + gateway := interfaceGateway(egressInterface.Index, true) + if gateway.IsValid() { + rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet4Port)) + } else { + ruleLogger.Debug("no IPv4 gateway on ", egress, ", relying on the default route") + } + } + if inet6Port.IsValid() { + gateway := interfaceGateway(egressInterface.Index, false) + if gateway.IsValid() { + rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet6Port)) + } else { + ruleLogger.Debug("no IPv6 gateway on ", egress, ", relying on the default route") + } + } + // pf rules are last-match: the pass rules below override the route-to pin + // for destinations in connected subnets and for addresses owned by the host + // itself, so they reach local delivery on their own interface. + for _, prefix := range localPrefixes { + port := inet4Port + if !prefix.Addr().Is4() { + port = inet6Port + } + rules = append(rules, pfPassInRule(tunName, port, prefix)) + } + for _, address := range hostAddresses() { + port := inet4Port + if !address.Is4() { + port = inet6Port + } + if !port.IsValid() { + continue + } + rules = append(rules, pfPassInRule(tunName, port, netip.PrefixFrom(address, address.BitLen()))) + } + return rules +} + +// collectLocalSegments returns the connected subnets whose destinations bypass +// the route-to pin so the routing table delivers them on their own interface, +// plus the non-egress interfaces that then need their own masquerade rule. +// With a pinned egress only its own subnets bypass, matching the Linux backend. +func collectLocalSegments(egress string, boundInterface string, inet4Active bool, inet6Active bool) (prefixes []netip.Prefix, inet4Interfaces []string, inet6Interfaces []string) { + localInterfaces, err := net.Interfaces() + if err != nil { + return + } + for _, localInterface := range localInterfaces { + if boundInterface != "" && localInterface.Name != boundInterface { + continue + } + if localInterface.Flags&net.FlagUp == 0 || localInterface.Flags&net.FlagBroadcast == 0 || + localInterface.Flags&net.FlagLoopback != 0 || localInterface.Flags&net.FlagPointToPoint != 0 { + continue + } + interfaceAddrs, addrsErr := localInterface.Addrs() + if addrsErr != nil { + continue + } + var ( + hasInet4 bool + hasInet6 bool + ) + for _, interfaceAddr := range interfaceAddrs { + ipNet, isIPNet := interfaceAddr.(*net.IPNet) + if !isIPNet { + continue + } + address, valid := netip.AddrFromSlice(ipNet.IP) + if !valid { + continue + } + address = address.Unmap() + if address.IsLinkLocalUnicast() { + continue + } + if address.Is4() { + if !inet4Active { + continue + } + hasInet4 = true + } else { + if !inet6Active { + continue + } + hasInet6 = true + } + bits, _ := ipNet.Mask.Size() + prefix := netip.PrefixFrom(address, bits).Masked() + if !slices.Contains(prefixes, prefix) { + prefixes = append(prefixes, prefix) + } + } + if localInterface.Name == egress { + continue + } + if hasInet4 { + inet4Interfaces = append(inet4Interfaces, localInterface.Name) + } + if hasInet6 { + inet6Interfaces = append(inet6Interfaces, localInterface.Name) + } + } + return +} + +// hostAddresses stands in for pfctl's `self`, which expands to every address +// assigned to any interface at ruleset load time. +func hostAddresses() []netip.Addr { + interfaceAddrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + var addresses []netip.Addr + for _, interfaceAddr := range interfaceAddrs { + ipNet, isIPNet := interfaceAddr.(*net.IPNet) + if !isIPNet { + continue + } + address, valid := netip.AddrFromSlice(ipNet.IP) + if !valid { + continue + } + address = address.Unmap() + if !slices.Contains(addresses, address) { + addresses = append(addresses, address) + } + } + return addresses +} + +func pfScrubRule(egress string, port netip.Addr, maxMSS uint16) pfAnchorRule { + rule := pfRule{ + Action: pfActionScrub, + AF: pfFamily(port.Is4()), + Proto: unix.IPPROTO_TCP, + MaxMSS: maxMSS, + } + copy(rule.IfName[:], egress) + rule.Src.Addr = pfHostAddress(port) + return pfAnchorRule{RulesetIndex: pfRulesetScrub, Rule: rule} +} + +func pfNatRule(interfaceName string, port netip.Addr) pfAnchorRule { + rule := pfRule{ + Action: pfActionNat, + AF: pfFamily(port.Is4()), + } + rule.RPool.ProxyPort = [2]uint16{pfNatProxyPortLow, pfNatProxyPortHigh} + copy(rule.IfName[:], interfaceName) + rule.Src.Addr = pfHostAddress(port) + return pfAnchorRule{ + RulesetIndex: pfRulesetNat, + Rule: rule, + Pool: pfPoolAddr{Addr: pfDynamicInterfaceAddress(interfaceName, port.Is4())}, + } +} + +func pfPassInRule(tunName string, port netip.Addr, destination netip.Prefix) pfAnchorRule { + rule := pfRule{ + Action: pfActionPass, + Direction: pfDirectionIn, + AF: pfFamily(port.Is4()), + KeepState: pfStateNormal, + } + copy(rule.IfName[:], tunName) + rule.Src.Addr = pfHostAddress(port) + if destination.IsValid() { + rule.Dst.Addr = pfPrefixAddress(destination) + } + return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule} +} + +func pfRouteToRule(tunName string, egress string, gateway netip.Addr, port netip.Addr) pfAnchorRule { + anchorRule := pfPassInRule(tunName, port, netip.Prefix{}) + anchorRule.Rule.RouteAction = pfRouteActionRouteTo + anchorRule.Pool = pfPoolAddr{Addr: pfHostAddress(gateway)} + copy(anchorRule.Pool.IfName[:], egress) + return anchorRule +} + +// Assigning the port as the utun's point-to-point destination makes the kernel +// install the host route itself; a plain interface route against an address-less +// utun fails with ENETUNREACH. +func assignBridgePortAddress(tunName string, local netip.Addr, port netip.Addr) error { + if !port.IsValid() { + return nil + } + err := assignPointToPointAddress(tunName, local, port) + if err != nil { + return E.Cause(err, "assign bridge address") + } + err = addInterfaceHostRoute(port, tunName) + if err != nil { + return E.Cause(err, "add bridge host route") + } + return nil +} + +func enableDarwinForwarding(forwardingLogger logger.ContextLogger, inet4Active bool, inet6Active bool) []sysctlState { + var restore []sysctlState + enable := func(name string) { + mib := forwardingMibs[name] + value, err := getSysctlInt32(mib) + if err != nil { + forwardingLogger.Debug(E.Cause(err, "read ", name)) + return + } + if value == 1 { + return + } + err = setSysctlInt32(mib, 1) + if err != nil { + forwardingLogger.Debug(E.Cause(err, "enable ", name)) + return + } + restore = append(restore, sysctlState{name: name, value: strconv.Itoa(int(value))}) + } + if inet4Active { + enable("net.inet.ip.forwarding") + } + if inet6Active { + enable("net.inet6.ip6.forwarding") + } + return restore +} + +func restoreDarwinForwarding(states []sysctlState) { + for _, state := range states { + value, err := strconv.Atoi(state.value) + if err != nil { + continue + } + _ = setSysctlInt32(forwardingMibs[state.name], int32(value)) + } +} diff --git a/protocol/bridge/service.go b/protocol/bridge/service.go new file mode 100644 index 000000000..6fc56e9f8 --- /dev/null +++ b/protocol/bridge/service.go @@ -0,0 +1,99 @@ +//go:build linux || darwin + +package bridge + +import ( + "net/netip" + "os" + "sync" + + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/x/list" +) + +type serviceBase struct { + logger logger.ContextLogger + mtu int + inet4Port netip.Addr + inet6Port netip.Addr + tunName string + tunFileDescriptor int + forwardingRestore []sysctlState + + networkMonitor tun.NetworkUpdateMonitor + monitorElement *list.Element[tun.NetworkUpdateCallback] + + access sync.Mutex + egressName string + closed bool + applyEgress func() +} + +func (s *serviceBase) FileDescriptor() int { + return s.tunFileDescriptor +} + +func (s *serviceBase) Name() string { + return s.tunName +} + +func (s *serviceBase) Inet6Active() bool { + return s.inet6Port.IsValid() +} + +func (s *serviceBase) SetEgress(interfaceName string) error { + s.access.Lock() + defer s.access.Unlock() + if s.closed { + return os.ErrClosed + } + s.egressName = interfaceName + s.applyEgress() + return nil +} + +func (s *serviceBase) syncEgress() { + s.access.Lock() + defer s.access.Unlock() + if s.closed { + return + } + s.applyEgress() +} + +func (s *serviceBase) startNetworkMonitor() { + networkMonitor, err := tun.NewNetworkUpdateMonitor(s.logger) + if err != nil { + s.logger.Debug(E.Cause(err, "create network monitor, egress will not track route changes")) + return + } + s.monitorElement = networkMonitor.RegisterCallback(func() { s.syncEgress() }) + s.networkMonitor = networkMonitor + err = networkMonitor.Start() + if err != nil { + s.logger.Debug(E.Cause(err, "start network monitor, egress will not track route changes")) + } +} + +func (s *serviceBase) beginClose() bool { + s.access.Lock() + if s.closed { + s.access.Unlock() + return false + } + s.closed = true + networkMonitor := s.networkMonitor + monitorElement := s.monitorElement + s.networkMonitor = nil + s.monitorElement = nil + s.access.Unlock() + if networkMonitor != nil { + if monitorElement != nil { + networkMonitor.UnregisterCallback(monitorElement) + } + _ = networkMonitor.Close() + } + return true +} diff --git a/protocol/bridge/service_darwin.go b/protocol/bridge/service_darwin.go new file mode 100644 index 000000000..8e5ad474c --- /dev/null +++ b/protocol/bridge/service_darwin.go @@ -0,0 +1,208 @@ +package bridge + +import ( + "net/netip" + "os" + "slices" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/sys/unix" +) + +type ServiceOptions struct { + MTU int + Inet4Port netip.Addr + Inet6Port netip.Addr + Interface string + Logger logger.ContextLogger +} + +type Service struct { + serviceBase + + boundInterface string + inet4Local netip.Addr + inet6Local netip.Addr + anchorName string + pfDevice *pfDevice + pfToken uint64 + currentRules []pfAnchorRule +} + +func NewService(options ServiceOptions) (*Service, error) { + if !options.Inet4Port.IsValid() { + return nil, E.New("missing bridge IPv4 port address") + } + serviceLogger := options.Logger + if serviceLogger == nil { + serviceLogger = logger.NOP() + } + instance := &Service{ + serviceBase: serviceBase{ + logger: serviceLogger, + mtu: options.MTU, + inet4Port: options.Inet4Port, + inet6Port: options.Inet6Port, + tunFileDescriptor: -1, + }, + boundInterface: options.Interface, + } + instance.applyEgress = instance.syncEgressLocked + index, err := bridgeIndexOf(options.Inet4Port) + if err != nil { + return nil, err + } + instance.inet4Local = addressAt(bridgeInet4LocalBase, index) + instance.inet6Local = addressAt(bridgeInet6LocalBase, index) + err = instance.start() + if err != nil { + instance.Close() + return nil, err + } + return instance, nil +} + +func bridgeIndexOf(inet4Port netip.Addr) (uint32, error) { + for index := range uint32(bridgeMaxInstances) { + if addressAt(bridgeInet4Base, index) == inet4Port { + return index, nil + } + } + return 0, E.New("unexpected bridge IPv4 port address: ", inet4Port) +} + +func (s *Service) start() error { + tunFileDescriptor, tunName, err := createBridgeTun(s.mtu) + if err != nil { + return E.Cause(err, "create bridge tun") + } + s.tunFileDescriptor = tunFileDescriptor + s.tunName = tunName + s.anchorName = bridgeAnchor(tunName) + s.forwardingRestore = enableDarwinForwarding(s.logger, s.inet4Port.IsValid(), s.inet6Port.IsValid()) + err = assignBridgePortAddress(tunName, s.inet4Local, s.inet4Port) + if err != nil { + return E.Cause(err, "add bridge route") + } + err = assignBridgePortAddress(tunName, s.inet6Local, s.inet6Port) + if err != nil { + s.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding")) + s.inet6Port = netip.Addr{} + } + device, err := openPfDevice() + if err != nil { + return E.Cause(err, "enable pf") + } + s.pfDevice = device + token, err := device.StartReference() + if err != nil { + return E.Cause(err, "enable pf") + } + s.pfToken = token + s.startNetworkMonitor() + return nil +} + +func (s *Service) syncEgressLocked() { + var rules []pfAnchorRule + if s.egressName != "" { + rules = buildBridgeAnchorRules(s.logger, s.tunName, s.egressName, s.boundInterface, s.inet4Port, s.inet6Port) + } + if slices.Equal(rules, s.currentRules) { + return + } + err := s.pfDevice.LoadAnchor(s.anchorName, rules) + if err != nil { + s.logger.Debug(E.Cause(err, "apply bridge egress ", s.egressName)) + return + } + s.currentRules = rules + if len(rules) == 0 { + s.logger.Debug("bridge egress unavailable, dropping forwarded traffic") + } else { + s.logger.Debug("bridge egress ", s.egressName) + } +} + +func (s *Service) Close() error { + if !s.beginClose() { + return nil + } + s.access.Lock() + defer s.access.Unlock() + if s.pfDevice != nil { + // anchorName is set before pfDevice is opened, so a non-nil pfDevice means + // it holds the intended target (the sub-anchor on macOS, "" on iOS). + _ = s.pfDevice.LoadAnchor(s.anchorName, nil) + if s.pfToken != 0 { + _ = s.pfDevice.StopReference(s.pfToken) + } + _ = s.pfDevice.Close() + s.pfDevice = nil + } + restoreDarwinForwarding(s.forwardingRestore) + s.forwardingRestore = nil + if s.tunFileDescriptor >= 0 { + _ = unix.Close(s.tunFileDescriptor) + s.tunFileDescriptor = -1 + } + return nil +} + +// The stock macOS /etc/pf.conf ends its main ruleset with wildcard +// nat/rdr/scrub/anchor references to "com.apple/*", so rules loaded into a +// sub-anchor below it are evaluated without editing the main ruleset. iOS ships +// no /etc/pf.conf and no such references, leaving the main ruleset empty and +// pf-unused; there an anchor is never traversed, so we own the main ruleset +// directly (anchor "") instead. +func bridgeAnchor(tunName string) string { + _, err := os.Stat("/etc/pf.conf") + if err != nil { + return "" + } + return "com.apple/sing-box-" + tunName +} + +func createBridgeTun(mtu int) (int, string, error) { + tunFd, err := unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2) + if err != nil { + return -1, "", os.NewSyscallError("socket", err) + } + ctlInfo := &unix.CtlInfo{} + copy(ctlInfo.Name[:], "com.apple.net.utun_control") + err = unix.IoctlCtlInfo(tunFd, ctlInfo) + if err != nil { + unix.Close(tunFd) + return -1, "", os.NewSyscallError("IoctlCtlInfo", err) + } + err = unix.Connect(tunFd, &unix.SockaddrCtl{ID: ctlInfo.Id, Unit: 0}) + if err != nil { + unix.Close(tunFd) + return -1, "", os.NewSyscallError("Connect", err) + } + name, err := unix.GetsockoptString( + tunFd, + 2, /* #define SYSPROTO_CONTROL 2 */ + 2, /* #define UTUN_OPT_IFNAME 2 */ + ) + if err != nil { + unix.Close(tunFd) + return -1, "", os.NewSyscallError("GetsockoptString", err) + } + socketFd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) + if err != nil { + unix.Close(tunFd) + return -1, "", os.NewSyscallError("socket", err) + } + ifr := unix.IfreqMTU{MTU: int32(mtu)} + copy(ifr.Name[:], name) + err = unix.IoctlSetIfreqMTU(socketFd, &ifr) + unix.Close(socketFd) + if err != nil { + unix.Close(tunFd) + return -1, "", os.NewSyscallError("IoctlSetIfreqMTU", err) + } + return tunFd, name, nil +} diff --git a/protocol/bridge/service_linux.go b/protocol/bridge/service_linux.go new file mode 100644 index 000000000..7e5f821be --- /dev/null +++ b/protocol/bridge/service_linux.go @@ -0,0 +1,251 @@ +package bridge + +import ( + "net" + "net/netip" + + "github.com/sagernet/netlink" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/sys/unix" +) + +type ServiceOptions struct { + BridgeName string + MTU int + Inet4Port netip.Addr + Inet6Port netip.Addr + RuleIndex int + RouteTable int + Logger logger.ContextLogger +} + +type Service struct { + serviceBase + + ruleIndex int + routeTable int + nftTableName string + clampMTU int +} + +func NewService(options ServiceOptions) (*Service, error) { + if !options.Inet4Port.IsValid() { + return nil, E.New("missing bridge IPv4 port address") + } + if options.RouteTable == 0 { + return nil, E.New("missing bridge route table index") + } + serviceLogger := options.Logger + if serviceLogger == nil { + serviceLogger = logger.NOP() + } + instance := &Service{ + serviceBase: serviceBase{ + logger: serviceLogger, + mtu: options.MTU, + inet4Port: options.Inet4Port, + inet6Port: options.Inet6Port, + tunFileDescriptor: -1, + }, + ruleIndex: options.RuleIndex, + routeTable: options.RouteTable, + } + instance.applyEgress = instance.syncEgressLocked + err := instance.start(options.BridgeName) + if err != nil { + instance.Close() + return nil, err + } + return instance, nil +} + +func (s *Service) start(bridgeName string) error { + s.tunName = tun.CalculateInterfaceName(bridgeName) + s.nftTableName = "sing-box-" + s.tunName + tunFileDescriptor, err := openBridgeTun(s.tunName) + if err != nil { + return E.Cause(err, "create bridge tun") + } + s.tunFileDescriptor = tunFileDescriptor + tunLink, err := netlink.LinkByName(s.tunName) + if err != nil { + return E.Cause(err, "find bridge tun") + } + err = netlink.LinkSetMTU(tunLink, s.mtu) + if err != nil { + return E.Cause(err, "set bridge tun mtu") + } + err = netlink.LinkSetUp(tunLink) + if err != nil { + return E.Cause(err, "set bridge tun up") + } + inet6Active, err := setupBridgeNetfilter(s.logger, s.nftTableName, s.tunName, s.inet6Port.IsValid()) + if err != nil { + return E.Cause(err, "set up bridge netfilter") + } + if !inet6Active { + s.inet6Port = netip.Addr{} + } + s.forwardingRestore = enableBridgeForwarding(s.logger, s.tunName, s.inet4Port.IsValid(), s.inet6Port.IsValid()) + err = setupBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET, s.inet4Port) + if err != nil { + return E.Cause(err, "set up bridge routing") + } + err = setupBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET6, s.inet6Port) + if err != nil { + s.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding")) + removeBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET6, s.inet6Port) + s.inet6Port = netip.Addr{} + } + for _, family := range activeBridgeFamilies(s.inet6Port) { + blackholeBridgeDefault(s.routeTable, family) + } + s.startNetworkMonitor() + return nil +} + +func (s *Service) syncEgressLocked() { + flushBridgeRouteTable(s.routeTable) + if s.egressName == "" { + for _, family := range activeBridgeFamilies(s.inet6Port) { + blackholeBridgeDefault(s.routeTable, family) + } + return + } + link, err := netlink.LinkByName(s.egressName) + if err != nil { + for _, family := range activeBridgeFamilies(s.inet6Port) { + blackholeBridgeDefault(s.routeTable, family) + } + s.logger.Debug("bridge egress ", s.egressName, " absent, dropping forwarded traffic") + return + } + for _, family := range activeBridgeFamilies(s.inet6Port) { + s.syncEgressFamilyLocked(family, link.Attrs().Index) + } + s.updateClampLocked(link.Attrs().MTU) +} + +// Unlike the in-process backend this copies routes from every table: on Android +// netd leaves the main table empty and keeps each network's routes in its own +// table, resolvable only through fwmark rules that forwarded packets never carry. +func (s *Service) syncEgressFamilyLocked(family int, linkIndex int) { + routes, err := netlink.RouteListFiltered(family, &netlink.Route{ + LinkIndex: linkIndex, + Table: unix.RT_TABLE_UNSPEC, + }, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE) + if err != nil { + blackholeBridgeDefault(s.routeTable, family) + return + } + var defaultRoute *netlink.Route + for _, route := range routes { + if route.Table == unix.RT_TABLE_LOCAL || route.Table == s.routeTable { + continue + } + if route.Type != unix.RTN_UNICAST { + continue + } + if isDefaultDestination(route.Dst) { + if defaultRoute == nil { + pinned := route + defaultRoute = &pinned + } + continue + } + if route.Gw != nil { + continue + } + connected := route + connected.Table = s.routeTable + connected.ILinkIndex = 0 + _ = netlink.RouteReplace(&connected) + } + if defaultRoute == nil { + blackholeBridgeDefault(s.routeTable, family) + s.logger.Debug("no default route on bridge egress ", s.egressName) + return + } + defaultRoute.Table = s.routeTable + defaultRoute.ILinkIndex = 0 + err = netlink.RouteReplace(defaultRoute) + if err != nil { + blackholeBridgeDefault(s.routeTable, family) + s.logger.Debug(E.Cause(err, "pin bridge egress default route")) + } +} + +func (s *Service) updateClampLocked(egressMTU int) { + mtu := s.mtu + if egressMTU >= 576 && egressMTU < mtu { + mtu = egressMTU + } + if mtu == s.clampMTU { + return + } + err := setupBridgeClamp(s.nftTableName, s.tunName, s.inet4Port, s.inet6Port, mtu) + if err != nil { + s.logger.Debug(E.Cause(err, "update bridge MSS clamp")) + return + } + s.clampMTU = mtu +} + +func (s *Service) Close() error { + if !s.beginClose() { + return nil + } + s.access.Lock() + defer s.access.Unlock() + if s.tunName != "" { + cleanupBridgeNetfilter(s.nftTableName) + removeBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET, s.inet4Port) + removeBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET6, s.inet6Port) + flushBridgeRouteTable(s.routeTable) + } + restoreBridgeForwarding(s.forwardingRestore) + s.forwardingRestore = nil + if s.tunFileDescriptor >= 0 { + _ = unix.Close(s.tunFileDescriptor) + s.tunFileDescriptor = -1 + } + return nil +} + +func isDefaultDestination(destination *net.IPNet) bool { + if destination == nil { + return true + } + ones, _ := destination.Mask.Size() + return ones == 0 +} + +func openBridgeTun(name string) (int, error) { + tunFileDescriptor, err := unix.Open("/dev/net/tun", unix.O_RDWR, 0) + if err != nil { + tunFileDescriptor, err = unix.Open("/dev/tun", unix.O_RDWR, 0) + } + if err != nil { + return -1, E.Cause(err, "open tun control device") + } + ifreq, err := unix.NewIfreq(name) + if err != nil { + unix.Close(tunFileDescriptor) + return -1, err + } + ifreq.SetUint16(unix.IFF_TUN | unix.IFF_NO_PI) + err = unix.IoctlIfreq(tunFileDescriptor, unix.TUNSETIFF, ifreq) + if err != nil { + unix.Close(tunFileDescriptor) + return -1, E.Cause(err, "TUNSETIFF") + } + err = unix.SetNonblock(tunFileDescriptor, true) + if err != nil { + unix.Close(tunFileDescriptor) + return -1, E.Cause(err, "set nonblock") + } + return tunFileDescriptor, nil +} diff --git a/route/route.go b/route/route.go index f6c4e6591..980057a7d 100644 --- a/route/route.go +++ b/route/route.go @@ -432,6 +432,9 @@ func applyRouteOptionsOverride(metadata *adapter.InboundContext, routeOptions *R Fqdn: metadata.Destination.Fqdn, } } + if routeOptions.UDPTimeout > 0 { + metadata.UDPTimeout = routeOptions.UDPTimeout + } } func (r *Router) preMatchFlow(ctx context.Context, metadata *adapter.InboundContext, packetDestination M.Socksaddr, matchedRule adapter.Rule, outboundTag string) adapter.PreMatchResult { @@ -472,6 +475,19 @@ func (r *Router) preMatchFlow(ctx context.Context, metadata *adapter.InboundCont return continueResult } result := adapter.PreMatchResult{Action: adapter.PreMatchFlow, Outbound: outbound} + if metadata.Network == N.NetworkUDP { + if metadata.UDPTimeout > 0 { + result.UDPTimeout = metadata.UDPTimeout + } else { + protocol := metadata.Protocol + if protocol == "" { + protocol = C.PortProtocols[metadata.Destination.Port] + } + if protocol != "" { + result.UDPTimeout = C.ProtocolTimeouts[protocol] + } + } + } if metadata.Destination.IsDomain() { if !metadata.FakeIP { return continueResult