From e26e0d7be7e43282dd43e840cb934911072877d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 3 Mar 2026 20:59:13 +0800 Subject: [PATCH 01/57] Add MAC and hostname rule items --- adapter/inbound.go | 3 + adapter/neighbor.go | 13 + adapter/router.go | 2 + docs/configuration/dns/rule.md | 31 ++ docs/configuration/dns/rule.zh.md | 31 ++ docs/configuration/inbound/tun.md | 35 ++ docs/configuration/inbound/tun.zh.md | 35 ++ docs/configuration/route/index.md | 31 ++ docs/configuration/route/index.zh.md | 31 ++ docs/configuration/route/rule.md | 31 ++ docs/configuration/route/rule.zh.md | 31 ++ go.mod | 6 +- go.sum | 4 +- option/route.go | 2 + option/rule.go | 2 + option/rule_dns.go | 2 + option/tun.go | 2 + protocol/tun/inbound.go | 18 + route/neighbor_resolver_linux.go | 596 +++++++++++++++++++++ route/neighbor_resolver_stub.go | 14 + route/route.go | 17 + route/router.go | 39 ++ route/rule/rule_default.go | 10 + route/rule/rule_dns.go | 10 + route/rule/rule_item_source_hostname.go | 42 ++ route/rule/rule_item_source_mac_address.go | 48 ++ route/rule_conds.go | 8 + 27 files changed, 1089 insertions(+), 5 deletions(-) create mode 100644 adapter/neighbor.go create mode 100644 route/neighbor_resolver_linux.go create mode 100644 route/neighbor_resolver_stub.go create mode 100644 route/rule/rule_item_source_hostname.go create mode 100644 route/rule/rule_item_source_mac_address.go diff --git a/adapter/inbound.go b/adapter/inbound.go index b32e9f8278..acd6f4912c 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -2,6 +2,7 @@ package adapter import ( "context" + "net" "net/netip" "time" @@ -82,6 +83,8 @@ type InboundContext struct { SourceGeoIPCode string GeoIPCode string ProcessInfo *ConnectionOwner + SourceMACAddress net.HardwareAddr + SourceHostname string QueryType uint16 FakeIP bool diff --git a/adapter/neighbor.go b/adapter/neighbor.go new file mode 100644 index 0000000000..920398f674 --- /dev/null +++ b/adapter/neighbor.go @@ -0,0 +1,13 @@ +package adapter + +import ( + "net" + "net/netip" +) + +type NeighborResolver interface { + LookupMAC(address netip.Addr) (net.HardwareAddr, bool) + LookupHostname(address netip.Addr) (string, bool) + Start() error + Close() error +} diff --git a/adapter/router.go b/adapter/router.go index 3d5310c4ee..82e6881a60 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -26,6 +26,8 @@ type Router interface { RuleSet(tag string) (RuleSet, bool) Rules() []Rule NeedFindProcess() bool + NeedFindNeighbor() bool + NeighborResolver() NeighborResolver AppendTracker(tracker ConnectionTracker) ResetNetwork() } diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 6407e1bf60..262a23e629 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -2,6 +2,11 @@ icon: material/alert-decagram --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [interface_address](#interface_address) @@ -149,6 +154,12 @@ icon: material/alert-decagram "default_interface_address": [ "2000::/3" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "wifi_ssid": [ "My WIFI" ], @@ -408,6 +419,26 @@ Matches network interface (same values as `network_type`) address. Match default interface address. +#### source_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `route.find_neighbor` enabled. + +Match source device MAC address. + +#### source_hostname + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `route.find_neighbor` enabled. + +Match source device hostname from DHCP leases. + #### wifi_ssid !!! quote "" diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 588e0736a4..4bf60b9862 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -2,6 +2,11 @@ icon: material/alert-decagram --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [interface_address](#interface_address) @@ -149,6 +154,12 @@ icon: material/alert-decagram "default_interface_address": [ "2000::/3" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "wifi_ssid": [ "My WIFI" ], @@ -407,6 +418,26 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配默认接口地址。 +#### source_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + +匹配源设备 MAC 地址。 + +#### source_hostname + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + +匹配源设备从 DHCP 租约获取的主机名。 + #### wifi_ssid !!! quote "" diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 7e67e488c5..25008b67ce 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) @@ -125,6 +130,12 @@ icon: material/new-box "exclude_package": [ "com.android.captiveportallogin" ], + "include_mac_address": [ + "00:11:22:33:44:55" + ], + "exclude_mac_address": [ + "66:77:88:99:aa:bb" + ], "platform": { "http_proxy": { "enabled": false, @@ -548,6 +559,30 @@ Limit android packages in route. Exclude android packages in route. +#### include_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +Limit MAC addresses in route. Not limited by default. + +Conflict with `exclude_mac_address`. + +#### exclude_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +Exclude MAC addresses in route. + +Conflict with `include_mac_address`. + #### platform Platform-specific settings, provided by client applications. diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index d8520aedbd..5b6b95ccaf 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) @@ -126,6 +131,12 @@ icon: material/new-box "exclude_package": [ "com.android.captiveportallogin" ], + "include_mac_address": [ + "00:11:22:33:44:55" + ], + "exclude_mac_address": [ + "66:77:88:99:aa:bb" + ], "platform": { "http_proxy": { "enabled": false, @@ -536,6 +547,30 @@ TCP/IP 栈。 排除路由的 Android 应用包名。 +#### include_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 和 `auto_redirect` 已启用。 + +限制被路由的 MAC 地址。默认不限制。 + +与 `exclude_mac_address` 冲突。 + +#### exclude_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 和 `auto_redirect` 已启用。 + +排除路由的 MAC 地址。 + +与 `include_mac_address` 冲突。 + #### platform 平台特定的设置,由客户端应用提供。 diff --git a/docs/configuration/route/index.md b/docs/configuration/route/index.md index 1fc9bfd231..01e405614e 100644 --- a/docs/configuration/route/index.md +++ b/docs/configuration/route/index.md @@ -4,6 +4,11 @@ icon: material/alert-decagram # Route +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [find_neighbor](#find_neighbor) + :material-plus: [dhcp_lease_files](#dhcp_lease_files) + !!! quote "Changes in sing-box 1.12.0" :material-plus: [default_domain_resolver](#default_domain_resolver) @@ -35,6 +40,8 @@ icon: material/alert-decagram "override_android_vpn": false, "default_interface": "", "default_mark": 0, + "find_neighbor": false, + "dhcp_lease_files": [], "default_domain_resolver": "", // or {} "default_network_strategy": "", "default_network_type": [], @@ -107,6 +114,30 @@ Set routing mark by default. Takes no effect if `outbound.routing_mark` is set. +#### find_neighbor + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux. + +Enable neighbor resolution for source MAC address and hostname lookup. + +Required for `source_mac_address` and `source_hostname` rule items. + +#### dhcp_lease_files + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux. + +Custom DHCP lease file paths for hostname and MAC address resolution. + +Automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea) if empty. + #### default_domain_resolver !!! question "Since sing-box 1.12.0" diff --git a/docs/configuration/route/index.zh.md b/docs/configuration/route/index.zh.md index fa50bfe7d9..84ce76723c 100644 --- a/docs/configuration/route/index.zh.md +++ b/docs/configuration/route/index.zh.md @@ -4,6 +4,11 @@ icon: material/alert-decagram # 路由 +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [find_neighbor](#find_neighbor) + :material-plus: [dhcp_lease_files](#dhcp_lease_files) + !!! quote "sing-box 1.12.0 中的更改" :material-plus: [default_domain_resolver](#default_domain_resolver) @@ -37,6 +42,8 @@ icon: material/alert-decagram "override_android_vpn": false, "default_interface": "", "default_mark": 0, + "find_neighbor": false, + "dhcp_lease_files": [], "default_network_strategy": "", "default_fallback_delay": "" } @@ -106,6 +113,30 @@ icon: material/alert-decagram 如果设置了 `outbound.routing_mark` 设置,则不生效。 +#### find_neighbor + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux。 + +启用邻居解析以查找源 MAC 地址和主机名。 + +`source_mac_address` 和 `source_hostname` 规则项需要此选项。 + +#### dhcp_lease_files + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux。 + +用于主机名和 MAC 地址解析的自定义 DHCP 租约文件路径。 + +为空时自动从常见 DHCP 服务器(dnsmasq、odhcpd、ISC dhcpd、Kea)检测。 + #### default_domain_resolver !!! question "自 sing-box 1.12.0 起" diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 31f768fe23..d226571096 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [interface_address](#interface_address) @@ -159,6 +164,12 @@ icon: material/new-box "tailscale", "wireguard" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "rule_set": [ "geoip-cn", "geosite-cn" @@ -449,6 +460,26 @@ Match specified outbounds' preferred routes. | `tailscale` | Match MagicDNS domains and peers' allowed IPs | | `wireguard` | Match peers's allowed IPs | +#### source_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `route.find_neighbor` enabled. + +Match source device MAC address. + +#### source_hostname + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `route.find_neighbor` enabled. + +Match source device hostname from DHCP leases. + #### rule_set !!! question "Since sing-box 1.8.0" diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 1ffe57d688..597e655f6e 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [interface_address](#interface_address) @@ -156,6 +161,12 @@ icon: material/new-box "tailscale", "wireguard" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "rule_set": [ "geoip-cn", "geosite-cn" @@ -446,6 +457,26 @@ icon: material/new-box | `tailscale` | 匹配 MagicDNS 域名和对端的 allowed IPs | | `wireguard` | 匹配对端的 allowed IPs | +#### source_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + +匹配源设备 MAC 地址。 + +#### source_hostname + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + +匹配源设备从 DHCP 租约获取的主机名。 + #### rule_set !!! question "自 sing-box 1.8.0 起" diff --git a/go.mod b/go.mod index c00a9a2d5e..af891de1cf 100644 --- a/go.mod +++ b/go.mod @@ -14,11 +14,13 @@ require ( github.com/godbus/dbus/v5 v5.2.2 github.com/gofrs/uuid/v5 v5.4.0 github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 + github.com/jsimonetti/rtnetlink v1.4.0 github.com/keybase/go-keychain v0.0.1 github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/mdlayher/netlink v1.9.0 github.com/metacubex/utls v1.8.4 github.com/mholt/acmez/v3 v3.1.6 github.com/miekg/dns v1.1.72 @@ -39,7 +41,7 @@ require ( github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 - github.com/sagernet/sing-tun v0.8.2 + github.com/sagernet/sing-tun v0.8.3-0.20260305131414-5083da5745ed github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260303140313-3bcf9a4b9349 @@ -92,11 +94,9 @@ require ( github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libdns/libdns v1.1.1 // indirect - github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect diff --git a/go.sum b/go.sum index 9348343a07..d5634c3651 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-tun v0.8.2 h1:rQr/x3eQCHh3oleIaoJdPdJwqzZp4+QWcJLT0Wz2xKY= -github.com/sagernet/sing-tun v0.8.2/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= +github.com/sagernet/sing-tun v0.8.3-0.20260305131414-5083da5745ed h1:0XZgwnEX2HgQ/0J0The6KPEAezBz5bLl18PMTRHNN9E= +github.com/sagernet/sing-tun v0.8.3-0.20260305131414-5083da5745ed/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= diff --git a/option/route.go b/option/route.go index f4b6539156..0c3e576d13 100644 --- a/option/route.go +++ b/option/route.go @@ -9,6 +9,8 @@ type RouteOptions struct { RuleSet []RuleSet `json:"rule_set,omitempty"` Final string `json:"final,omitempty"` FindProcess bool `json:"find_process,omitempty"` + FindNeighbor bool `json:"find_neighbor,omitempty"` + DHCPLeaseFiles badoption.Listable[string] `json:"dhcp_lease_files,omitempty"` AutoDetectInterface bool `json:"auto_detect_interface,omitempty"` OverrideAndroidVPN bool `json:"override_android_vpn,omitempty"` DefaultInterface string `json:"default_interface,omitempty"` diff --git a/option/rule.go b/option/rule.go index 3e7fd8771b..b792ccf4b2 100644 --- a/option/rule.go +++ b/option/rule.go @@ -103,6 +103,8 @@ type RawDefaultRule struct { InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"` NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` + SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` diff --git a/option/rule_dns.go b/option/rule_dns.go index dbc1657898..880b96ac54 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -106,6 +106,8 @@ type RawDefaultDNSRule struct { InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"` NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` + SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` diff --git a/option/tun.go b/option/tun.go index 72b6e456ba..fda028b69e 100644 --- a/option/tun.go +++ b/option/tun.go @@ -39,6 +39,8 @@ type TunInboundOptions struct { IncludeAndroidUser badoption.Listable[int] `json:"include_android_user,omitempty"` IncludePackage badoption.Listable[string] `json:"include_package,omitempty"` ExcludePackage badoption.Listable[string] `json:"exclude_package,omitempty"` + IncludeMACAddress badoption.Listable[string] `json:"include_mac_address,omitempty"` + ExcludeMACAddress badoption.Listable[string] `json:"exclude_mac_address,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` Stack string `json:"stack,omitempty"` Platform *TunPlatformOptions `json:"platform,omitempty"` diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index df9344b817..6f10849321 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -156,6 +156,22 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if nfQueue == 0 { nfQueue = tun.DefaultAutoRedirectNFQueue } + var includeMACAddress []net.HardwareAddr + for i, macString := range options.IncludeMACAddress { + mac, macErr := net.ParseMAC(macString) + if macErr != nil { + return nil, E.Cause(macErr, "parse include_mac_address[", i, "]") + } + includeMACAddress = append(includeMACAddress, mac) + } + var excludeMACAddress []net.HardwareAddr + for i, macString := range options.ExcludeMACAddress { + mac, macErr := net.ParseMAC(macString) + if macErr != nil { + return nil, E.Cause(macErr, "parse exclude_mac_address[", i, "]") + } + excludeMACAddress = append(excludeMACAddress, mac) + } networkManager := service.FromContext[adapter.NetworkManager](ctx) multiPendingPackets := C.IsDarwin && ((options.Stack == "gvisor" && tunMTU < 32768) || (options.Stack != "gvisor" && options.MTU <= 9000)) inbound := &Inbound{ @@ -193,6 +209,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo IncludeAndroidUser: options.IncludeAndroidUser, IncludePackage: options.IncludePackage, ExcludePackage: options.ExcludePackage, + IncludeMACAddress: includeMACAddress, + ExcludeMACAddress: excludeMACAddress, InterfaceMonitor: networkManager.InterfaceMonitor(), EXP_MultiPendingPackets: multiPendingPackets, }, diff --git a/route/neighbor_resolver_linux.go b/route/neighbor_resolver_linux.go new file mode 100644 index 0000000000..40db5766ad --- /dev/null +++ b/route/neighbor_resolver_linux.go @@ -0,0 +1,596 @@ +//go:build linux + +package route + +import ( + "bufio" + "encoding/binary" + "encoding/hex" + "net" + "net/netip" + "os" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "github.com/jsimonetti/rtnetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +var defaultLeaseFiles = []string{ + "/tmp/dhcp.leases", + "/var/lib/dhcp/dhcpd.leases", + "/var/lib/dhcpd/dhcpd.leases", + "/var/lib/kea/kea-leases4.csv", + "/var/lib/kea/kea-leases6.csv", +} + +type neighborResolver struct { + logger logger.ContextLogger + leaseFiles []string + access sync.RWMutex + neighborIPToMAC map[netip.Addr]net.HardwareAddr + leaseIPToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string + watcher *fswatch.Watcher + done chan struct{} +} + +func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) { + if len(leaseFiles) == 0 { + for _, path := range defaultLeaseFiles { + info, err := os.Stat(path) + if err == nil && info.Size() > 0 { + leaseFiles = append(leaseFiles, path) + } + } + } + return &neighborResolver{ + logger: resolverLogger, + leaseFiles: leaseFiles, + neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr), + leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + done: make(chan struct{}), + }, nil +} + +func (r *neighborResolver) Start() error { + err := r.loadNeighborTable() + if err != nil { + r.logger.Warn(E.Cause(err, "load neighbor table")) + } + r.reloadLeaseFiles() + go r.subscribeNeighborUpdates() + if len(r.leaseFiles) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: r.leaseFiles, + Logger: r.logger, + Callback: func(_ string) { + r.reloadLeaseFiles() + }, + }) + if err != nil { + r.logger.Warn(E.Cause(err, "create lease file watcher")) + } else { + r.watcher = watcher + err = watcher.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start lease file watcher")) + } + } + } + return nil +} + +func (r *neighborResolver) Close() error { + close(r.done) + if r.watcher != nil { + return r.watcher.Close() + } + return nil +} + +func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.neighborIPToMAC[address] + if found { + return mac, true + } + mac, found = r.leaseIPToMAC[address] + if found { + return mac, true + } + mac, found = extractMACFromEUI64(address) + if found { + return mac, true + } + return nil, false +} + +func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, macFound := r.neighborIPToMAC[address] + if !macFound { + mac, macFound = r.leaseIPToMAC[address] + } + if !macFound { + mac, macFound = extractMACFromEUI64(address) + } + if macFound { + hostname, found = r.macToHostname[mac.String()] + if found { + return hostname, true + } + } + return "", false +} + +func (r *neighborResolver) loadNeighborTable() error { + connection, err := rtnetlink.Dial(nil) + if err != nil { + return E.Cause(err, "dial rtnetlink") + } + defer connection.Close() + neighbors, err := connection.Neigh.List() + if err != nil { + return E.Cause(err, "list neighbors") + } + r.access.Lock() + defer r.access.Unlock() + for _, neigh := range neighbors { + if neigh.Attributes == nil { + continue + } + if neigh.Attributes.LLAddress == nil || len(neigh.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neigh.Attributes.Address) + if !ok { + continue + } + r.neighborIPToMAC[address] = slices.Clone(neigh.Attributes.LLAddress) + } + return nil +} + +func (r *neighborResolver) subscribeNeighborUpdates() { + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + r.logger.Warn(E.Cause(err, "subscribe neighbor updates")) + return + } + defer connection.Close() + for { + select { + case <-r.done: + return + default: + } + err = connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + r.logger.Warn(E.Cause(err, "set netlink read deadline")) + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + } + r.logger.Warn(E.Cause(err, "receive neighbor update")) + continue + } + for _, message := range messages { + switch message.Header.Type { + case unix.RTM_NEWNEIGH: + var neighMessage rtnetlink.NeighMessage + unmarshalErr := neighMessage.UnmarshalBinary(message.Data) + if unmarshalErr != nil { + continue + } + if neighMessage.Attributes == nil { + continue + } + if neighMessage.Attributes.LLAddress == nil || len(neighMessage.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neighMessage.Attributes.Address) + if !ok { + continue + } + r.access.Lock() + r.neighborIPToMAC[address] = slices.Clone(neighMessage.Attributes.LLAddress) + r.access.Unlock() + case unix.RTM_DELNEIGH: + var neighMessage rtnetlink.NeighMessage + unmarshalErr := neighMessage.UnmarshalBinary(message.Data) + if unmarshalErr != nil { + continue + } + if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neighMessage.Attributes.Address) + if !ok { + continue + } + r.access.Lock() + delete(r.neighborIPToMAC, address) + r.access.Unlock() + } + } + } +} + +func (r *neighborResolver) reloadLeaseFiles() { + leaseIPToMAC := make(map[netip.Addr]net.HardwareAddr) + ipToHostname := make(map[netip.Addr]string) + macToHostname := make(map[string]string) + for _, path := range r.leaseFiles { + r.parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname) + } + r.access.Lock() + r.leaseIPToMAC = leaseIPToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() +} + +func (r *neighborResolver) parseLeaseFile(path string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + if strings.HasSuffix(path, "kea-leases4.csv") { + r.parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases6.csv") { + r.parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "dhcpd.leases") { + r.parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname) + return + } + r.parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname) +} + +func (r *neighborResolver) parseDnsmasqOdhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "duid ") { + continue + } + if strings.HasPrefix(line, "# ") { + r.parseOdhcpdLine(line[2:], ipToMAC, ipToHostname, macToHostname) + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + expiry, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + if expiry != 0 && expiry < now { + continue + } + if strings.Contains(fields[1], ":") { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + ipToMAC[address] = mac + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } else { + var mac net.HardwareAddr + if len(fields) >= 5 { + duid, duidErr := parseDUID(fields[4]) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } + } +} + +func (r *neighborResolver) parseOdhcpdLine(line string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + fields := strings.Fields(line) + if len(fields) < 5 { + return + } + validTime, err := strconv.ParseInt(fields[4], 10, 64) + if err != nil { + return + } + if validTime == 0 { + return + } + if validTime > 0 && validTime < time.Now().Unix() { + return + } + hostname := fields[3] + if hostname == "-" || strings.HasPrefix(hostname, `broken\x20`) { + hostname = "" + } + if len(fields) >= 8 && fields[2] == "ipv4" { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + return + } + addressField := fields[7] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + return + } + address = address.Unmap() + ipToMAC[address] = mac + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + return + } + var mac net.HardwareAddr + duidHex := fields[1] + duidBytes, hexErr := hex.DecodeString(duidHex) + if hexErr == nil { + mac, _ = extractMACFromDUID(duidBytes) + } + for i := 7; i < len(fields); i++ { + addressField := fields[i] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func (r *neighborResolver) parseISCDhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentHostname string + var currentActive bool + var inLease bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") { + ipString := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {") + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(ipString)) + if addrOK { + currentIP = parsed.Unmap() + inLease = true + currentMAC = nil + currentHostname = "" + currentActive = false + } + continue + } + if line == "}" && inLease { + if currentActive && currentMAC != nil { + ipToMAC[currentIP] = currentMAC + if currentHostname != "" { + ipToHostname[currentIP] = currentHostname + macToHostname[currentMAC.String()] = currentHostname + } + } else { + delete(ipToMAC, currentIP) + delete(ipToHostname, currentIP) + } + inLease = false + continue + } + if !inLease { + continue + } + if strings.HasPrefix(line, "hardware ethernet ") { + macString := strings.TrimSuffix(strings.TrimPrefix(line, "hardware ethernet "), ";") + parsed, macErr := net.ParseMAC(macString) + if macErr == nil { + currentMAC = parsed + } + } else if strings.HasPrefix(line, "client-hostname ") { + hostname := strings.TrimSuffix(strings.TrimPrefix(line, "client-hostname "), ";") + hostname = strings.Trim(hostname, "\"") + if hostname != "" { + currentHostname = hostname + } + } else if strings.HasPrefix(line, "binding state ") { + state := strings.TrimSuffix(strings.TrimPrefix(line, "binding state "), ";") + currentActive = state == "active" + } + } +} + +func (r *neighborResolver) parseKeaCSV4(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 10 { + continue + } + if fields[9] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + ipToMAC[address] = mac + hostname := "" + if len(fields) > 8 { + hostname = fields[8] + } + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } +} + +func (r *neighborResolver) parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 14 { + continue + } + if fields[13] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + var mac net.HardwareAddr + if fields[12] != "" { + mac, _ = net.ParseMAC(fields[12]) + } + if mac == nil { + duid, duidErr := hex.DecodeString(strings.ReplaceAll(fields[1], ":", "")) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + hostname := "" + if len(fields) > 11 { + hostname = fields[11] + } + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func extractMACFromDUID(duid []byte) (net.HardwareAddr, bool) { + if len(duid) < 4 { + return nil, false + } + duidType := binary.BigEndian.Uint16(duid[0:2]) + hwType := binary.BigEndian.Uint16(duid[2:4]) + if hwType != 1 { + return nil, false + } + switch duidType { + case 1: + if len(duid) < 14 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[8:14])), true + case 3: + if len(duid) < 10 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[4:10])), true + } + return nil, false +} + +func extractMACFromEUI64(address netip.Addr) (net.HardwareAddr, bool) { + if !address.Is6() { + return nil, false + } + b := address.As16() + if b[11] != 0xff || b[12] != 0xfe { + return nil, false + } + return net.HardwareAddr{b[8] ^ 0x02, b[9], b[10], b[13], b[14], b[15]}, true +} + +func parseDUID(s string) ([]byte, error) { + cleaned := strings.ReplaceAll(s, ":", "") + return hex.DecodeString(cleaned) +} diff --git a/route/neighbor_resolver_stub.go b/route/neighbor_resolver_stub.go new file mode 100644 index 0000000000..9288892a8d --- /dev/null +++ b/route/neighbor_resolver_stub.go @@ -0,0 +1,14 @@ +//go:build !linux + +package route + +import ( + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +func newNeighborResolver(_ logger.ContextLogger, _ []string) (adapter.NeighborResolver, error) { + return nil, os.ErrInvalid +} diff --git a/route/route.go b/route/route.go index cdd7ba2509..324b76829a 100644 --- a/route/route.go +++ b/route/route.go @@ -439,6 +439,23 @@ func (r *Router) matchRule( metadata.ProcessInfo = processInfo } } + if r.neighborResolver != nil && metadata.SourceMACAddress == nil && metadata.Source.Addr.IsValid() { + mac, macFound := r.neighborResolver.LookupMAC(metadata.Source.Addr) + if macFound { + metadata.SourceMACAddress = mac + } + hostname, hostnameFound := r.neighborResolver.LookupHostname(metadata.Source.Addr) + if hostnameFound { + metadata.SourceHostname = hostname + if macFound { + r.logger.InfoContext(ctx, "found neighbor: ", mac, ", hostname: ", hostname) + } else { + r.logger.InfoContext(ctx, "found neighbor hostname: ", hostname) + } + } else if macFound { + r.logger.InfoContext(ctx, "found neighbor: ", mac) + } + } if metadata.Destination.Addr.IsValid() && r.dnsTransport.FakeIP() != nil && r.dnsTransport.FakeIP().Store().Contains(metadata.Destination.Addr) { domain, loaded := r.dnsTransport.FakeIP().Store().Lookup(metadata.Destination.Addr) if !loaded { diff --git a/route/router.go b/route/router.go index 5c73cb1c9f..abc7ffa313 100644 --- a/route/router.go +++ b/route/router.go @@ -31,9 +31,12 @@ type Router struct { network adapter.NetworkManager rules []adapter.Rule needFindProcess bool + needFindNeighbor bool + leaseFiles []string ruleSets []adapter.RuleSet ruleSetMap map[string]adapter.RuleSet processSearcher process.Searcher + neighborResolver adapter.NeighborResolver pauseManager pause.Manager trackers []adapter.ConnectionTracker platformInterface adapter.PlatformInterface @@ -53,6 +56,8 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route rules: make([]adapter.Rule, 0, len(options.Rules)), ruleSetMap: make(map[string]adapter.RuleSet), needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, + needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || options.FindNeighbor, + leaseFiles: options.DHCPLeaseFiles, pauseManager: service.FromContext[pause.Manager](ctx), platformInterface: service.FromContext[adapter.PlatformInterface](ctx), } @@ -112,6 +117,7 @@ func (r *Router) Start(stage adapter.StartStage) error { } r.network.Initialize(r.ruleSets) needFindProcess := r.needFindProcess + needFindNeighbor := r.needFindNeighbor for _, ruleSet := range r.ruleSets { metadata := ruleSet.Metadata() if metadata.ContainsProcessRule { @@ -141,6 +147,24 @@ func (r *Router) Start(stage adapter.StartStage) error { } } } + r.needFindNeighbor = needFindNeighbor + if needFindNeighbor { + monitor.Start("initialize neighbor resolver") + resolver, err := newNeighborResolver(r.logger, r.leaseFiles) + monitor.Finish() + if err != nil { + if err != os.ErrInvalid { + r.logger.Warn(E.Cause(err, "create neighbor resolver")) + } + } else { + err = resolver.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } + } case adapter.StartStatePostStart: for i, rule := range r.rules { monitor.Start("initialize rule[", i, "]") @@ -172,6 +196,13 @@ func (r *Router) Start(stage adapter.StartStage) error { func (r *Router) Close() error { monitor := taskmonitor.New(r.logger, C.StopTimeout) var err error + if r.neighborResolver != nil { + monitor.Start("close neighbor resolver") + err = E.Append(err, r.neighborResolver.Close(), func(closeErr error) error { + return E.Cause(closeErr, "close neighbor resolver") + }) + monitor.Finish() + } for i, rule := range r.rules { monitor.Start("close rule[", i, "]") err = E.Append(err, rule.Close(), func(err error) error { @@ -206,6 +237,14 @@ func (r *Router) NeedFindProcess() bool { return r.needFindProcess } +func (r *Router) NeedFindNeighbor() bool { + return r.needFindNeighbor +} + +func (r *Router) NeighborResolver() adapter.NeighborResolver { + return r.neighborResolver +} + func (r *Router) ResetNetwork() { r.network.ResetNetwork() r.dns.ResetNetwork() diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 202fb3b36d..7ffdd521cb 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -260,6 +260,16 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.SourceMACAddress) > 0 { + item := NewSourceMACAddressItem(options.SourceMACAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceHostname) > 0 { + item := NewSourceHostnameItem(options.SourceHostname) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.PreferredBy) > 0 { item := NewPreferredByItem(ctx, options.PreferredBy) rule.items = append(rule.items, item) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 9235dd6fd9..957df8747d 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -261,6 +261,16 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.SourceMACAddress) > 0 { + item := NewSourceMACAddressItem(options.SourceMACAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceHostname) > 0 { + item := NewSourceHostnameItem(options.SourceHostname) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.RuleSet) > 0 { //nolint:staticcheck if options.Deprecated_RulesetIPCIDRMatchSource { diff --git a/route/rule/rule_item_source_hostname.go b/route/rule/rule_item_source_hostname.go new file mode 100644 index 0000000000..0df11c8c8a --- /dev/null +++ b/route/rule/rule_item_source_hostname.go @@ -0,0 +1,42 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*SourceHostnameItem)(nil) + +type SourceHostnameItem struct { + hostnames []string + hostnameMap map[string]bool +} + +func NewSourceHostnameItem(hostnameList []string) *SourceHostnameItem { + rule := &SourceHostnameItem{ + hostnames: hostnameList, + hostnameMap: make(map[string]bool), + } + for _, hostname := range hostnameList { + rule.hostnameMap[hostname] = true + } + return rule +} + +func (r *SourceHostnameItem) Match(metadata *adapter.InboundContext) bool { + if metadata.SourceHostname == "" { + return false + } + return r.hostnameMap[metadata.SourceHostname] +} + +func (r *SourceHostnameItem) String() string { + var description string + if len(r.hostnames) == 1 { + description = "source_hostname=" + r.hostnames[0] + } else { + description = "source_hostname=[" + strings.Join(r.hostnames, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_source_mac_address.go b/route/rule/rule_item_source_mac_address.go new file mode 100644 index 0000000000..feeadb1dbf --- /dev/null +++ b/route/rule/rule_item_source_mac_address.go @@ -0,0 +1,48 @@ +package rule + +import ( + "net" + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*SourceMACAddressItem)(nil) + +type SourceMACAddressItem struct { + addresses []string + addressMap map[string]bool +} + +func NewSourceMACAddressItem(addressList []string) *SourceMACAddressItem { + rule := &SourceMACAddressItem{ + addresses: addressList, + addressMap: make(map[string]bool), + } + for _, address := range addressList { + parsed, err := net.ParseMAC(address) + if err == nil { + rule.addressMap[parsed.String()] = true + } else { + rule.addressMap[address] = true + } + } + return rule +} + +func (r *SourceMACAddressItem) Match(metadata *adapter.InboundContext) bool { + if metadata.SourceMACAddress == nil { + return false + } + return r.addressMap[metadata.SourceMACAddress.String()] +} + +func (r *SourceMACAddressItem) String() string { + var description string + if len(r.addresses) == 1 { + description = "source_mac_address=" + r.addresses[0] + } else { + description = "source_mac_address=[" + strings.Join(r.addresses, " ") + "]" + } + return description +} diff --git a/route/rule_conds.go b/route/rule_conds.go index 55c4a058e2..22ce94fffd 100644 --- a/route/rule_conds.go +++ b/route/rule_conds.go @@ -45,6 +45,14 @@ func isProcessDNSRule(rule option.DefaultDNSRule) bool { return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 } +func isNeighborRule(rule option.DefaultRule) bool { + return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0 +} + +func isNeighborDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0 +} + func isWIFIRule(rule option.DefaultRule) bool { return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 } From 7f34daa2d2504e7ca8b734fe13d2a8d9a78105ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 5 Mar 2026 00:15:37 +0800 Subject: [PATCH 02/57] Add Android support for MAC and hostname rule items --- adapter/neighbor.go | 10 ++ adapter/platform.go | 4 + experimental/libbox/config.go | 12 +++ experimental/libbox/neighbor.go | 135 +++++++++++++++++++++++++++ experimental/libbox/neighbor_stub.go | 24 +++++ experimental/libbox/platform.go | 6 ++ experimental/libbox/service.go | 37 ++++++++ route/neighbor_resolver_linux.go | 85 ++--------------- route/neighbor_resolver_parse.go | 50 ++++++++++ route/neighbor_resolver_platform.go | 84 +++++++++++++++++ route/neighbor_table_linux.go | 68 ++++++++++++++ route/router.go | 33 +++++-- 12 files changed, 462 insertions(+), 86 deletions(-) create mode 100644 experimental/libbox/neighbor.go create mode 100644 experimental/libbox/neighbor_stub.go create mode 100644 route/neighbor_resolver_parse.go create mode 100644 route/neighbor_resolver_platform.go create mode 100644 route/neighbor_table_linux.go diff --git a/adapter/neighbor.go b/adapter/neighbor.go index 920398f674..d917db5b7a 100644 --- a/adapter/neighbor.go +++ b/adapter/neighbor.go @@ -5,9 +5,19 @@ import ( "net/netip" ) +type NeighborEntry struct { + Address netip.Addr + MACAddress net.HardwareAddr + Hostname string +} + type NeighborResolver interface { LookupMAC(address netip.Addr) (net.HardwareAddr, bool) LookupHostname(address netip.Addr) (string, bool) Start() error Close() error } + +type NeighborUpdateListener interface { + UpdateNeighborTable(entries []NeighborEntry) +} diff --git a/adapter/platform.go b/adapter/platform.go index 95db93c646..12ab82a219 100644 --- a/adapter/platform.go +++ b/adapter/platform.go @@ -36,6 +36,10 @@ type PlatformInterface interface { UsePlatformNotification() bool SendNotification(notification *Notification) error + + UsePlatformNeighborResolver() bool + StartNeighborMonitor(listener NeighborUpdateListener) error + CloseNeighborMonitor(listener NeighborUpdateListener) error } type FindConnectionOwnerRequest struct { diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 122425d293..54369bf770 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -144,6 +144,18 @@ func (s *platformInterfaceStub) SendNotification(notification *adapter.Notificat return nil } +func (s *platformInterfaceStub) UsePlatformNeighborResolver() bool { + return false +} + +func (s *platformInterfaceStub) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (s *platformInterfaceStub) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool { return false } diff --git a/experimental/libbox/neighbor.go b/experimental/libbox/neighbor.go new file mode 100644 index 0000000000..b2ded5f7a1 --- /dev/null +++ b/experimental/libbox/neighbor.go @@ -0,0 +1,135 @@ +//go:build linux + +package libbox + +import ( + "net" + "net/netip" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +type NeighborEntry struct { + Address string + MACAddress string + Hostname string +} + +type NeighborEntryIterator interface { + Next() *NeighborEntry + HasNext() bool +} + +type NeighborSubscription struct { + done chan struct{} +} + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + return nil, E.Cause(err, "subscribe neighbor updates") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, connection, table) + return subscription, nil +} + +func (s *NeighborSubscription) Close() { + close(s.done) +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, connection *netlink.Conn, table map[netip.Addr]net.HardwareAddr) { + defer connection.Close() + for { + select { + case <-s.done: + return + default: + } + err := connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + changed := false + for _, message := range messages { + address, mac, isDelete, ok := route.ParseNeighborMessage(message) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} + +func tableToIterator(table map[netip.Addr]net.HardwareAddr) NeighborEntryIterator { + entries := make([]*NeighborEntry, 0, len(table)) + for address, mac := range table { + entries = append(entries, &NeighborEntry{ + Address: address.String(), + MACAddress: mac.String(), + }) + } + return &neighborEntryIterator{entries} +} + +type neighborEntryIterator struct { + entries []*NeighborEntry +} + +func (i *neighborEntryIterator) HasNext() bool { + return len(i.entries) > 0 +} + +func (i *neighborEntryIterator) Next() *NeighborEntry { + if len(i.entries) == 0 { + return nil + } + entry := i.entries[0] + i.entries = i.entries[1:] + return entry +} diff --git a/experimental/libbox/neighbor_stub.go b/experimental/libbox/neighbor_stub.go new file mode 100644 index 0000000000..95f6dc7d6f --- /dev/null +++ b/experimental/libbox/neighbor_stub.go @@ -0,0 +1,24 @@ +//go:build !linux + +package libbox + +import "os" + +type NeighborEntry struct { + Address string + MACAddress string + Hostname string +} + +type NeighborEntryIterator interface { + Next() *NeighborEntry + HasNext() bool +} + +type NeighborSubscription struct{} + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + return nil, os.ErrInvalid +} + +func (s *NeighborSubscription) Close() {} diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go index 63c54ccf2c..3b1b0f3204 100644 --- a/experimental/libbox/platform.go +++ b/experimental/libbox/platform.go @@ -21,6 +21,12 @@ type PlatformInterface interface { SystemCertificates() StringIterator ClearDNSCache() SendNotification(notification *Notification) error + StartNeighborMonitor(listener NeighborUpdateListener) error + CloseNeighborMonitor(listener NeighborUpdateListener) error +} + +type NeighborUpdateListener interface { + UpdateNeighborTable(entries NeighborEntryIterator) } type ConnectionOwner struct { diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 3a13f6d169..458d0c66c5 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -220,6 +220,43 @@ func (w *platformInterfaceWrapper) SendNotification(notification *adapter.Notifi return w.iif.SendNotification((*Notification)(notification)) } +func (w *platformInterfaceWrapper) UsePlatformNeighborResolver() bool { + return true +} + +func (w *platformInterfaceWrapper) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return w.iif.StartNeighborMonitor(&neighborUpdateListenerWrapper{listener: listener}) +} + +func (w *platformInterfaceWrapper) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return w.iif.CloseNeighborMonitor(nil) +} + +type neighborUpdateListenerWrapper struct { + listener adapter.NeighborUpdateListener +} + +func (w *neighborUpdateListenerWrapper) UpdateNeighborTable(entries NeighborEntryIterator) { + var result []adapter.NeighborEntry + for entries.HasNext() { + entry := entries.Next() + address, err := netip.ParseAddr(entry.Address) + if err != nil { + continue + } + macAddress, err := net.ParseMAC(entry.MACAddress) + if err != nil { + continue + } + result = append(result, adapter.NeighborEntry{ + Address: address, + MACAddress: macAddress, + Hostname: entry.Hostname, + }) + } + w.listener.UpdateNeighborTable(result) +} + func AvailablePort(startPort int32) (int32, error) { for port := int(startPort); ; port++ { if port > 65535 { diff --git a/route/neighbor_resolver_linux.go b/route/neighbor_resolver_linux.go index 40db5766ad..111cc6f040 100644 --- a/route/neighbor_resolver_linux.go +++ b/route/neighbor_resolver_linux.go @@ -4,7 +4,6 @@ package route import ( "bufio" - "encoding/binary" "encoding/hex" "net" "net/netip" @@ -204,43 +203,17 @@ func (r *neighborResolver) subscribeNeighborUpdates() { continue } for _, message := range messages { - switch message.Header.Type { - case unix.RTM_NEWNEIGH: - var neighMessage rtnetlink.NeighMessage - unmarshalErr := neighMessage.UnmarshalBinary(message.Data) - if unmarshalErr != nil { - continue - } - if neighMessage.Attributes == nil { - continue - } - if neighMessage.Attributes.LLAddress == nil || len(neighMessage.Attributes.Address) == 0 { - continue - } - address, ok := netip.AddrFromSlice(neighMessage.Attributes.Address) - if !ok { - continue - } - r.access.Lock() - r.neighborIPToMAC[address] = slices.Clone(neighMessage.Attributes.LLAddress) - r.access.Unlock() - case unix.RTM_DELNEIGH: - var neighMessage rtnetlink.NeighMessage - unmarshalErr := neighMessage.UnmarshalBinary(message.Data) - if unmarshalErr != nil { - continue - } - if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 { - continue - } - address, ok := netip.AddrFromSlice(neighMessage.Attributes.Address) - if !ok { - continue - } - r.access.Lock() + address, mac, isDelete, ok := ParseNeighborMessage(message) + if !ok { + continue + } + r.access.Lock() + if isDelete { delete(r.neighborIPToMAC, address) - r.access.Unlock() + } else { + r.neighborIPToMAC[address] = mac } + r.access.Unlock() } } } @@ -554,43 +527,3 @@ func (r *neighborResolver) parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]ne } } } - -func extractMACFromDUID(duid []byte) (net.HardwareAddr, bool) { - if len(duid) < 4 { - return nil, false - } - duidType := binary.BigEndian.Uint16(duid[0:2]) - hwType := binary.BigEndian.Uint16(duid[2:4]) - if hwType != 1 { - return nil, false - } - switch duidType { - case 1: - if len(duid) < 14 { - return nil, false - } - return net.HardwareAddr(slices.Clone(duid[8:14])), true - case 3: - if len(duid) < 10 { - return nil, false - } - return net.HardwareAddr(slices.Clone(duid[4:10])), true - } - return nil, false -} - -func extractMACFromEUI64(address netip.Addr) (net.HardwareAddr, bool) { - if !address.Is6() { - return nil, false - } - b := address.As16() - if b[11] != 0xff || b[12] != 0xfe { - return nil, false - } - return net.HardwareAddr{b[8] ^ 0x02, b[9], b[10], b[13], b[14], b[15]}, true -} - -func parseDUID(s string) ([]byte, error) { - cleaned := strings.ReplaceAll(s, ":", "") - return hex.DecodeString(cleaned) -} diff --git a/route/neighbor_resolver_parse.go b/route/neighbor_resolver_parse.go new file mode 100644 index 0000000000..1979b7eabc --- /dev/null +++ b/route/neighbor_resolver_parse.go @@ -0,0 +1,50 @@ +package route + +import ( + "encoding/binary" + "encoding/hex" + "net" + "net/netip" + "slices" + "strings" +) + +func extractMACFromDUID(duid []byte) (net.HardwareAddr, bool) { + if len(duid) < 4 { + return nil, false + } + duidType := binary.BigEndian.Uint16(duid[0:2]) + hwType := binary.BigEndian.Uint16(duid[2:4]) + if hwType != 1 { + return nil, false + } + switch duidType { + case 1: + if len(duid) < 14 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[8:14])), true + case 3: + if len(duid) < 10 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[4:10])), true + } + return nil, false +} + +func extractMACFromEUI64(address netip.Addr) (net.HardwareAddr, bool) { + if !address.Is6() { + return nil, false + } + b := address.As16() + if b[11] != 0xff || b[12] != 0xfe { + return nil, false + } + return net.HardwareAddr{b[8] ^ 0x02, b[9], b[10], b[13], b[14], b[15]}, true +} + +func parseDUID(s string) ([]byte, error) { + cleaned := strings.ReplaceAll(s, ":", "") + return hex.DecodeString(cleaned) +} diff --git a/route/neighbor_resolver_platform.go b/route/neighbor_resolver_platform.go new file mode 100644 index 0000000000..ddb9a99592 --- /dev/null +++ b/route/neighbor_resolver_platform.go @@ -0,0 +1,84 @@ +package route + +import ( + "net" + "net/netip" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +type platformNeighborResolver struct { + logger logger.ContextLogger + platform adapter.PlatformInterface + access sync.RWMutex + ipToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string +} + +func newPlatformNeighborResolver(resolverLogger logger.ContextLogger, platform adapter.PlatformInterface) adapter.NeighborResolver { + return &platformNeighborResolver{ + logger: resolverLogger, + platform: platform, + ipToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + } +} + +func (r *platformNeighborResolver) Start() error { + return r.platform.StartNeighborMonitor(r) +} + +func (r *platformNeighborResolver) Close() error { + return r.platform.CloseNeighborMonitor(r) +} + +func (r *platformNeighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.ipToMAC[address] + if found { + return mac, true + } + return extractMACFromEUI64(address) +} + +func (r *platformNeighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, found := r.ipToMAC[address] + if !found { + mac, found = extractMACFromEUI64(address) + } + if !found { + return "", false + } + hostname, found = r.macToHostname[mac.String()] + return hostname, found +} + +func (r *platformNeighborResolver) UpdateNeighborTable(entries []adapter.NeighborEntry) { + ipToMAC := make(map[netip.Addr]net.HardwareAddr) + ipToHostname := make(map[netip.Addr]string) + macToHostname := make(map[string]string) + for _, entry := range entries { + ipToMAC[entry.Address] = entry.MACAddress + if entry.Hostname != "" { + ipToHostname[entry.Address] = entry.Hostname + macToHostname[entry.MACAddress.String()] = entry.Hostname + } + } + r.access.Lock() + r.ipToMAC = ipToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() + r.logger.Info("updated neighbor table: ", len(entries), " entries") +} diff --git a/route/neighbor_table_linux.go b/route/neighbor_table_linux.go new file mode 100644 index 0000000000..61a214fd3a --- /dev/null +++ b/route/neighbor_table_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package route + +import ( + "net" + "net/netip" + "slices" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/jsimonetti/rtnetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func ReadNeighborEntries() ([]adapter.NeighborEntry, error) { + connection, err := rtnetlink.Dial(nil) + if err != nil { + return nil, E.Cause(err, "dial rtnetlink") + } + defer connection.Close() + neighbors, err := connection.Neigh.List() + if err != nil { + return nil, E.Cause(err, "list neighbors") + } + var entries []adapter.NeighborEntry + for _, neighbor := range neighbors { + if neighbor.Attributes == nil { + continue + } + if neighbor.Attributes.LLAddress == nil || len(neighbor.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neighbor.Attributes.Address) + if !ok { + continue + } + entries = append(entries, adapter.NeighborEntry{ + Address: address, + MACAddress: slices.Clone(neighbor.Attributes.LLAddress), + }) + } + return entries, nil +} + +func ParseNeighborMessage(message netlink.Message) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) { + var neighMessage rtnetlink.NeighMessage + err := neighMessage.UnmarshalBinary(message.Data) + if err != nil { + return + } + if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 { + return + } + address, ok = netip.AddrFromSlice(neighMessage.Attributes.Address) + if !ok { + return + } + isDelete = message.Header.Type == unix.RTM_DELNEIGH + if !isDelete && neighMessage.Attributes.LLAddress == nil { + ok = false + return + } + macAddress = slices.Clone(neighMessage.Attributes.LLAddress) + return +} diff --git a/route/router.go b/route/router.go index abc7ffa313..59eded3157 100644 --- a/route/router.go +++ b/route/router.go @@ -149,21 +149,34 @@ func (r *Router) Start(stage adapter.StartStage) error { } r.needFindNeighbor = needFindNeighbor if needFindNeighbor { - monitor.Start("initialize neighbor resolver") - resolver, err := newNeighborResolver(r.logger, r.leaseFiles) - monitor.Finish() - if err != nil { - if err != os.ErrInvalid { - r.logger.Warn(E.Cause(err, "create neighbor resolver")) - } - } else { - err = resolver.Start() + if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() { + monitor.Start("initialize neighbor resolver") + resolver := newPlatformNeighborResolver(r.logger, r.platformInterface) + err := resolver.Start() + monitor.Finish() if err != nil { - r.logger.Warn(E.Cause(err, "start neighbor resolver")) + r.logger.Error(E.Cause(err, "start neighbor resolver")) } else { r.neighborResolver = resolver } } + if r.neighborResolver == nil { + monitor.Start("initialize neighbor resolver") + resolver, err := newNeighborResolver(r.logger, r.leaseFiles) + monitor.Finish() + if err != nil { + if err != os.ErrInvalid { + r.logger.Error(E.Cause(err, "create neighbor resolver")) + } + } else { + err = resolver.Start() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } + } } case adapter.StartStatePostStart: for i, rule := range r.rules { From 503e4d855185dfd214b5766b4e76ca747e878bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 6 Mar 2026 08:47:37 +0800 Subject: [PATCH 03/57] Add macOS support for MAC and hostname rule items --- experimental/libbox/neighbor.go | 86 +----- experimental/libbox/neighbor_darwin.go | 123 ++++++++ experimental/libbox/neighbor_linux.go | 88 ++++++ experimental/libbox/neighbor_stub.go | 19 +- experimental/libbox/platform.go | 1 + experimental/libbox/service.go | 6 +- route/neighbor_resolver_darwin.go | 239 +++++++++++++++ route/neighbor_resolver_lease.go | 386 +++++++++++++++++++++++++ route/neighbor_resolver_linux.go | 313 +------------------- route/neighbor_resolver_stub.go | 2 +- route/neighbor_table_darwin.go | 104 +++++++ route/router.go | 3 +- 12 files changed, 956 insertions(+), 414 deletions(-) create mode 100644 experimental/libbox/neighbor_darwin.go create mode 100644 experimental/libbox/neighbor_linux.go create mode 100644 route/neighbor_resolver_darwin.go create mode 100644 route/neighbor_resolver_lease.go create mode 100644 route/neighbor_table_darwin.go diff --git a/experimental/libbox/neighbor.go b/experimental/libbox/neighbor.go index b2ded5f7a1..e38aa8023f 100644 --- a/experimental/libbox/neighbor.go +++ b/experimental/libbox/neighbor.go @@ -1,23 +1,13 @@ -//go:build linux - package libbox import ( "net" "net/netip" - "slices" - "time" - - "github.com/sagernet/sing-box/route" - E "github.com/sagernet/sing/common/exceptions" - - "github.com/mdlayher/netlink" - "golang.org/x/sys/unix" ) type NeighborEntry struct { Address string - MACAddress string + MacAddress string Hostname string } @@ -30,88 +20,16 @@ type NeighborSubscription struct { done chan struct{} } -func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { - entries, err := route.ReadNeighborEntries() - if err != nil { - return nil, E.Cause(err, "initial neighbor dump") - } - table := make(map[netip.Addr]net.HardwareAddr) - for _, entry := range entries { - table[entry.Address] = entry.MACAddress - } - listener.UpdateNeighborTable(tableToIterator(table)) - connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ - Groups: 1 << (unix.RTNLGRP_NEIGH - 1), - }) - if err != nil { - return nil, E.Cause(err, "subscribe neighbor updates") - } - subscription := &NeighborSubscription{ - done: make(chan struct{}), - } - go subscription.loop(listener, connection, table) - return subscription, nil -} - func (s *NeighborSubscription) Close() { close(s.done) } -func (s *NeighborSubscription) loop(listener NeighborUpdateListener, connection *netlink.Conn, table map[netip.Addr]net.HardwareAddr) { - defer connection.Close() - for { - select { - case <-s.done: - return - default: - } - err := connection.SetReadDeadline(time.Now().Add(3 * time.Second)) - if err != nil { - return - } - messages, err := connection.Receive() - if err != nil { - if nerr, ok := err.(net.Error); ok && nerr.Timeout() { - continue - } - select { - case <-s.done: - return - default: - } - continue - } - changed := false - for _, message := range messages { - address, mac, isDelete, ok := route.ParseNeighborMessage(message) - if !ok { - continue - } - if isDelete { - if _, exists := table[address]; exists { - delete(table, address) - changed = true - } - } else { - existing, exists := table[address] - if !exists || !slices.Equal(existing, mac) { - table[address] = mac - changed = true - } - } - } - if changed { - listener.UpdateNeighborTable(tableToIterator(table)) - } - } -} - func tableToIterator(table map[netip.Addr]net.HardwareAddr) NeighborEntryIterator { entries := make([]*NeighborEntry, 0, len(table)) for address, mac := range table { entries = append(entries, &NeighborEntry{ Address: address.String(), - MACAddress: mac.String(), + MacAddress: mac.String(), }) } return &neighborEntryIterator{entries} diff --git a/experimental/libbox/neighbor_darwin.go b/experimental/libbox/neighbor_darwin.go new file mode 100644 index 0000000000..d7484a69b4 --- /dev/null +++ b/experimental/libbox/neighbor_darwin.go @@ -0,0 +1,123 @@ +//go:build darwin + +package libbox + +import ( + "net" + "net/netip" + "os" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + xroute "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + return nil, E.Cause(err, "open route socket") + } + err = unix.SetNonblock(routeSocket, true) + if err != nil { + unix.Close(routeSocket) + return nil, E.Cause(err, "set route socket nonblock") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, routeSocket, table) + return subscription, nil +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, routeSocket int, table map[netip.Addr]net.HardwareAddr) { + routeSocketFile := os.NewFile(uintptr(routeSocket), "route") + defer routeSocketFile.Close() + buffer := buf.NewPacket() + defer buffer.Release() + for { + select { + case <-s.done: + return + default: + } + tv := unix.NsecToTimeval(int64(3 * time.Second)) + _ = unix.SetsockoptTimeval(routeSocket, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + n, err := routeSocketFile.Read(buffer.FreeBytes()) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + messages, err := xroute.ParseRIB(xroute.RIBTypeRoute, buffer.FreeBytes()[:n]) + if err != nil { + continue + } + changed := false + for _, message := range messages { + routeMessage, isRouteMessage := message.(*xroute.RouteMessage) + if !isRouteMessage { + continue + } + if routeMessage.Flags&unix.RTF_LLINFO == 0 { + continue + } + address, mac, isDelete, ok := route.ParseRouteNeighborMessage(routeMessage) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} + +func ReadBootpdLeases() NeighborEntryIterator { + leaseIPToMAC, ipToHostname, macToHostname := route.ReloadLeaseFiles([]string{"/var/db/dhcpd_leases"}) + entries := make([]*NeighborEntry, 0, len(leaseIPToMAC)) + for address, mac := range leaseIPToMAC { + entry := &NeighborEntry{ + Address: address.String(), + MacAddress: mac.String(), + } + hostname, found := ipToHostname[address] + if !found { + hostname = macToHostname[mac.String()] + } + entry.Hostname = hostname + entries = append(entries, entry) + } + return &neighborEntryIterator{entries} +} diff --git a/experimental/libbox/neighbor_linux.go b/experimental/libbox/neighbor_linux.go new file mode 100644 index 0000000000..ae10bdd2ee --- /dev/null +++ b/experimental/libbox/neighbor_linux.go @@ -0,0 +1,88 @@ +//go:build linux + +package libbox + +import ( + "net" + "net/netip" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + return nil, E.Cause(err, "subscribe neighbor updates") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, connection, table) + return subscription, nil +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, connection *netlink.Conn, table map[netip.Addr]net.HardwareAddr) { + defer connection.Close() + for { + select { + case <-s.done: + return + default: + } + err := connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + changed := false + for _, message := range messages { + address, mac, isDelete, ok := route.ParseNeighborMessage(message) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} diff --git a/experimental/libbox/neighbor_stub.go b/experimental/libbox/neighbor_stub.go index 95f6dc7d6f..d465bc7bb0 100644 --- a/experimental/libbox/neighbor_stub.go +++ b/experimental/libbox/neighbor_stub.go @@ -1,24 +1,9 @@ -//go:build !linux +//go:build !linux && !darwin package libbox import "os" -type NeighborEntry struct { - Address string - MACAddress string - Hostname string -} - -type NeighborEntryIterator interface { - Next() *NeighborEntry - HasNext() bool -} - -type NeighborSubscription struct{} - -func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { +func SubscribeNeighborTable(_ NeighborUpdateListener) (*NeighborSubscription, error) { return nil, os.ErrInvalid } - -func (s *NeighborSubscription) Close() {} diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go index 3b1b0f3204..d2cac4cf68 100644 --- a/experimental/libbox/platform.go +++ b/experimental/libbox/platform.go @@ -23,6 +23,7 @@ type PlatformInterface interface { SendNotification(notification *Notification) error StartNeighborMonitor(listener NeighborUpdateListener) error CloseNeighborMonitor(listener NeighborUpdateListener) error + RegisterMyInterface(name string) } type NeighborUpdateListener interface { diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 458d0c66c5..b521f0f8e9 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -78,6 +78,7 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO } options.FileDescriptor = dupFd w.myTunName = options.Name + w.iif.RegisterMyInterface(options.Name) return tun.New(*options) } @@ -240,11 +241,14 @@ func (w *neighborUpdateListenerWrapper) UpdateNeighborTable(entries NeighborEntr var result []adapter.NeighborEntry for entries.HasNext() { entry := entries.Next() + if entry == nil { + continue + } address, err := netip.ParseAddr(entry.Address) if err != nil { continue } - macAddress, err := net.ParseMAC(entry.MACAddress) + macAddress, err := net.ParseMAC(entry.MacAddress) if err != nil { continue } diff --git a/route/neighbor_resolver_darwin.go b/route/neighbor_resolver_darwin.go new file mode 100644 index 0000000000..a8884ae628 --- /dev/null +++ b/route/neighbor_resolver_darwin.go @@ -0,0 +1,239 @@ +//go:build darwin + +package route + +import ( + "net" + "net/netip" + "os" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +var defaultLeaseFiles = []string{ + "/var/db/dhcpd_leases", + "/tmp/dhcp.leases", +} + +type neighborResolver struct { + logger logger.ContextLogger + leaseFiles []string + access sync.RWMutex + neighborIPToMAC map[netip.Addr]net.HardwareAddr + leaseIPToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string + watcher *fswatch.Watcher + done chan struct{} +} + +func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) { + if len(leaseFiles) == 0 { + for _, path := range defaultLeaseFiles { + info, err := os.Stat(path) + if err == nil && info.Size() > 0 { + leaseFiles = append(leaseFiles, path) + } + } + } + return &neighborResolver{ + logger: resolverLogger, + leaseFiles: leaseFiles, + neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr), + leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + done: make(chan struct{}), + }, nil +} + +func (r *neighborResolver) Start() error { + err := r.loadNeighborTable() + if err != nil { + r.logger.Warn(E.Cause(err, "load neighbor table")) + } + r.doReloadLeaseFiles() + go r.subscribeNeighborUpdates() + if len(r.leaseFiles) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: r.leaseFiles, + Logger: r.logger, + Callback: func(_ string) { + r.doReloadLeaseFiles() + }, + }) + if err != nil { + r.logger.Warn(E.Cause(err, "create lease file watcher")) + } else { + r.watcher = watcher + err = watcher.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start lease file watcher")) + } + } + } + return nil +} + +func (r *neighborResolver) Close() error { + close(r.done) + if r.watcher != nil { + return r.watcher.Close() + } + return nil +} + +func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.neighborIPToMAC[address] + if found { + return mac, true + } + mac, found = r.leaseIPToMAC[address] + if found { + return mac, true + } + mac, found = extractMACFromEUI64(address) + if found { + return mac, true + } + return nil, false +} + +func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, macFound := r.neighborIPToMAC[address] + if !macFound { + mac, macFound = r.leaseIPToMAC[address] + } + if !macFound { + mac, macFound = extractMACFromEUI64(address) + } + if macFound { + hostname, found = r.macToHostname[mac.String()] + if found { + return hostname, true + } + } + return "", false +} + +func (r *neighborResolver) loadNeighborTable() error { + entries, err := ReadNeighborEntries() + if err != nil { + return err + } + r.access.Lock() + defer r.access.Unlock() + for _, entry := range entries { + r.neighborIPToMAC[entry.Address] = entry.MACAddress + } + return nil +} + +func (r *neighborResolver) subscribeNeighborUpdates() { + routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + r.logger.Warn(E.Cause(err, "subscribe neighbor updates")) + return + } + err = unix.SetNonblock(routeSocket, true) + if err != nil { + unix.Close(routeSocket) + r.logger.Warn(E.Cause(err, "set route socket nonblock")) + return + } + routeSocketFile := os.NewFile(uintptr(routeSocket), "route") + defer routeSocketFile.Close() + buffer := buf.NewPacket() + defer buffer.Release() + for { + select { + case <-r.done: + return + default: + } + err = setReadDeadline(routeSocketFile, 3*time.Second) + if err != nil { + r.logger.Warn(E.Cause(err, "set route socket read deadline")) + return + } + n, err := routeSocketFile.Read(buffer.FreeBytes()) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + } + r.logger.Warn(E.Cause(err, "receive neighbor update")) + continue + } + messages, err := route.ParseRIB(route.RIBTypeRoute, buffer.FreeBytes()[:n]) + if err != nil { + continue + } + for _, message := range messages { + routeMessage, isRouteMessage := message.(*route.RouteMessage) + if !isRouteMessage { + continue + } + if routeMessage.Flags&unix.RTF_LLINFO == 0 { + continue + } + address, mac, isDelete, ok := ParseRouteNeighborMessage(routeMessage) + if !ok { + continue + } + r.access.Lock() + if isDelete { + delete(r.neighborIPToMAC, address) + } else { + r.neighborIPToMAC[address] = mac + } + r.access.Unlock() + } + } +} + +func (r *neighborResolver) doReloadLeaseFiles() { + leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles) + r.access.Lock() + r.leaseIPToMAC = leaseIPToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() +} + +func setReadDeadline(file *os.File, timeout time.Duration) error { + rawConn, err := file.SyscallConn() + if err != nil { + return err + } + var controlErr error + err = rawConn.Control(func(fd uintptr) { + tv := unix.NsecToTimeval(int64(timeout)) + controlErr = unix.SetsockoptTimeval(int(fd), unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + }) + if err != nil { + return err + } + return controlErr +} diff --git a/route/neighbor_resolver_lease.go b/route/neighbor_resolver_lease.go new file mode 100644 index 0000000000..e3f9c0b464 --- /dev/null +++ b/route/neighbor_resolver_lease.go @@ -0,0 +1,386 @@ +package route + +import ( + "bufio" + "encoding/hex" + "net" + "net/netip" + "os" + "strconv" + "strings" + "time" +) + +func parseLeaseFile(path string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + if strings.HasSuffix(path, "dhcpd_leases") { + parseBootpdLeases(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases4.csv") { + parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases6.csv") { + parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "dhcpd.leases") { + parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname) + return + } + parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname) +} + +func ReloadLeaseFiles(leaseFiles []string) (leaseIPToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + leaseIPToMAC = make(map[netip.Addr]net.HardwareAddr) + ipToHostname = make(map[netip.Addr]string) + macToHostname = make(map[string]string) + for _, path := range leaseFiles { + parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname) + } + return +} + +func parseDnsmasqOdhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "duid ") { + continue + } + if strings.HasPrefix(line, "# ") { + parseOdhcpdLine(line[2:], ipToMAC, ipToHostname, macToHostname) + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + expiry, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + if expiry != 0 && expiry < now { + continue + } + if strings.Contains(fields[1], ":") { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + ipToMAC[address] = mac + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } else { + var mac net.HardwareAddr + if len(fields) >= 5 { + duid, duidErr := parseDUID(fields[4]) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } + } +} + +func parseOdhcpdLine(line string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + fields := strings.Fields(line) + if len(fields) < 5 { + return + } + validTime, err := strconv.ParseInt(fields[4], 10, 64) + if err != nil { + return + } + if validTime == 0 { + return + } + if validTime > 0 && validTime < time.Now().Unix() { + return + } + hostname := fields[3] + if hostname == "-" || strings.HasPrefix(hostname, `broken\x20`) { + hostname = "" + } + if len(fields) >= 8 && fields[2] == "ipv4" { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + return + } + addressField := fields[7] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + return + } + address = address.Unmap() + ipToMAC[address] = mac + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + return + } + var mac net.HardwareAddr + duidHex := fields[1] + duidBytes, hexErr := hex.DecodeString(duidHex) + if hexErr == nil { + mac, _ = extractMACFromDUID(duidBytes) + } + for i := 7; i < len(fields); i++ { + addressField := fields[i] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func parseISCDhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentHostname string + var currentActive bool + var inLease bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") { + ipString := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {") + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(ipString)) + if addrOK { + currentIP = parsed.Unmap() + inLease = true + currentMAC = nil + currentHostname = "" + currentActive = false + } + continue + } + if line == "}" && inLease { + if currentActive && currentMAC != nil { + ipToMAC[currentIP] = currentMAC + if currentHostname != "" { + ipToHostname[currentIP] = currentHostname + macToHostname[currentMAC.String()] = currentHostname + } + } else { + delete(ipToMAC, currentIP) + delete(ipToHostname, currentIP) + } + inLease = false + continue + } + if !inLease { + continue + } + if strings.HasPrefix(line, "hardware ethernet ") { + macString := strings.TrimSuffix(strings.TrimPrefix(line, "hardware ethernet "), ";") + parsed, macErr := net.ParseMAC(macString) + if macErr == nil { + currentMAC = parsed + } + } else if strings.HasPrefix(line, "client-hostname ") { + hostname := strings.TrimSuffix(strings.TrimPrefix(line, "client-hostname "), ";") + hostname = strings.Trim(hostname, "\"") + if hostname != "" { + currentHostname = hostname + } + } else if strings.HasPrefix(line, "binding state ") { + state := strings.TrimSuffix(strings.TrimPrefix(line, "binding state "), ";") + currentActive = state == "active" + } + } +} + +func parseKeaCSV4(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 10 { + continue + } + if fields[9] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + ipToMAC[address] = mac + hostname := "" + if len(fields) > 8 { + hostname = fields[8] + } + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } +} + +func parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 14 { + continue + } + if fields[13] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + var mac net.HardwareAddr + if fields[12] != "" { + mac, _ = net.ParseMAC(fields[12]) + } + if mac == nil { + duid, duidErr := hex.DecodeString(strings.ReplaceAll(fields[1], ":", "")) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + hostname := "" + if len(fields) > 11 { + hostname = fields[11] + } + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func parseBootpdLeases(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + var currentName string + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentLease int64 + var inBlock bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "{" { + inBlock = true + currentName = "" + currentIP = netip.Addr{} + currentMAC = nil + currentLease = 0 + continue + } + if line == "}" && inBlock { + if currentMAC != nil && currentIP.IsValid() { + if currentLease == 0 || currentLease >= now { + ipToMAC[currentIP] = currentMAC + if currentName != "" { + ipToHostname[currentIP] = currentName + macToHostname[currentMAC.String()] = currentName + } + } + } + inBlock = false + continue + } + if !inBlock { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + switch key { + case "name": + currentName = value + case "ip_address": + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(value)) + if addrOK { + currentIP = parsed.Unmap() + } + case "hw_address": + typeAndMAC, hasSep := strings.CutPrefix(value, "1,") + if hasSep { + mac, macErr := net.ParseMAC(typeAndMAC) + if macErr == nil { + currentMAC = mac + } + } + case "lease": + leaseHex := strings.TrimPrefix(value, "0x") + parsed, parseErr := strconv.ParseInt(leaseHex, 16, 64) + if parseErr == nil { + currentLease = parsed + } + } + } +} diff --git a/route/neighbor_resolver_linux.go b/route/neighbor_resolver_linux.go index 111cc6f040..b7991b4c89 100644 --- a/route/neighbor_resolver_linux.go +++ b/route/neighbor_resolver_linux.go @@ -3,14 +3,10 @@ package route import ( - "bufio" - "encoding/hex" "net" "net/netip" "os" "slices" - "strconv" - "strings" "sync" "time" @@ -69,14 +65,14 @@ func (r *neighborResolver) Start() error { if err != nil { r.logger.Warn(E.Cause(err, "load neighbor table")) } - r.reloadLeaseFiles() + r.doReloadLeaseFiles() go r.subscribeNeighborUpdates() if len(r.leaseFiles) > 0 { watcher, err := fswatch.NewWatcher(fswatch.Options{ Path: r.leaseFiles, Logger: r.logger, Callback: func(_ string) { - r.reloadLeaseFiles() + r.doReloadLeaseFiles() }, }) if err != nil { @@ -218,312 +214,11 @@ func (r *neighborResolver) subscribeNeighborUpdates() { } } -func (r *neighborResolver) reloadLeaseFiles() { - leaseIPToMAC := make(map[netip.Addr]net.HardwareAddr) - ipToHostname := make(map[netip.Addr]string) - macToHostname := make(map[string]string) - for _, path := range r.leaseFiles { - r.parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname) - } +func (r *neighborResolver) doReloadLeaseFiles() { + leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles) r.access.Lock() r.leaseIPToMAC = leaseIPToMAC r.ipToHostname = ipToHostname r.macToHostname = macToHostname r.access.Unlock() } - -func (r *neighborResolver) parseLeaseFile(path string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { - file, err := os.Open(path) - if err != nil { - return - } - defer file.Close() - if strings.HasSuffix(path, "kea-leases4.csv") { - r.parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname) - return - } - if strings.HasSuffix(path, "kea-leases6.csv") { - r.parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname) - return - } - if strings.HasSuffix(path, "dhcpd.leases") { - r.parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname) - return - } - r.parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname) -} - -func (r *neighborResolver) parseDnsmasqOdhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { - now := time.Now().Unix() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "duid ") { - continue - } - if strings.HasPrefix(line, "# ") { - r.parseOdhcpdLine(line[2:], ipToMAC, ipToHostname, macToHostname) - continue - } - fields := strings.Fields(line) - if len(fields) < 4 { - continue - } - expiry, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil { - continue - } - if expiry != 0 && expiry < now { - continue - } - if strings.Contains(fields[1], ":") { - mac, macErr := net.ParseMAC(fields[1]) - if macErr != nil { - continue - } - address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) - if !addrOK { - continue - } - address = address.Unmap() - ipToMAC[address] = mac - hostname := fields[3] - if hostname != "*" { - ipToHostname[address] = hostname - macToHostname[mac.String()] = hostname - } - } else { - var mac net.HardwareAddr - if len(fields) >= 5 { - duid, duidErr := parseDUID(fields[4]) - if duidErr == nil { - mac, _ = extractMACFromDUID(duid) - } - } - address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) - if !addrOK { - continue - } - address = address.Unmap() - if mac != nil { - ipToMAC[address] = mac - } - hostname := fields[3] - if hostname != "*" { - ipToHostname[address] = hostname - if mac != nil { - macToHostname[mac.String()] = hostname - } - } - } - } -} - -func (r *neighborResolver) parseOdhcpdLine(line string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { - fields := strings.Fields(line) - if len(fields) < 5 { - return - } - validTime, err := strconv.ParseInt(fields[4], 10, 64) - if err != nil { - return - } - if validTime == 0 { - return - } - if validTime > 0 && validTime < time.Now().Unix() { - return - } - hostname := fields[3] - if hostname == "-" || strings.HasPrefix(hostname, `broken\x20`) { - hostname = "" - } - if len(fields) >= 8 && fields[2] == "ipv4" { - mac, macErr := net.ParseMAC(fields[1]) - if macErr != nil { - return - } - addressField := fields[7] - slashIndex := strings.IndexByte(addressField, '/') - if slashIndex >= 0 { - addressField = addressField[:slashIndex] - } - address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) - if !addrOK { - return - } - address = address.Unmap() - ipToMAC[address] = mac - if hostname != "" { - ipToHostname[address] = hostname - macToHostname[mac.String()] = hostname - } - return - } - var mac net.HardwareAddr - duidHex := fields[1] - duidBytes, hexErr := hex.DecodeString(duidHex) - if hexErr == nil { - mac, _ = extractMACFromDUID(duidBytes) - } - for i := 7; i < len(fields); i++ { - addressField := fields[i] - slashIndex := strings.IndexByte(addressField, '/') - if slashIndex >= 0 { - addressField = addressField[:slashIndex] - } - address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) - if !addrOK { - continue - } - address = address.Unmap() - if mac != nil { - ipToMAC[address] = mac - } - if hostname != "" { - ipToHostname[address] = hostname - if mac != nil { - macToHostname[mac.String()] = hostname - } - } - } -} - -func (r *neighborResolver) parseISCDhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { - scanner := bufio.NewScanner(file) - var currentIP netip.Addr - var currentMAC net.HardwareAddr - var currentHostname string - var currentActive bool - var inLease bool - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") { - ipString := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {") - parsed, addrOK := netip.AddrFromSlice(net.ParseIP(ipString)) - if addrOK { - currentIP = parsed.Unmap() - inLease = true - currentMAC = nil - currentHostname = "" - currentActive = false - } - continue - } - if line == "}" && inLease { - if currentActive && currentMAC != nil { - ipToMAC[currentIP] = currentMAC - if currentHostname != "" { - ipToHostname[currentIP] = currentHostname - macToHostname[currentMAC.String()] = currentHostname - } - } else { - delete(ipToMAC, currentIP) - delete(ipToHostname, currentIP) - } - inLease = false - continue - } - if !inLease { - continue - } - if strings.HasPrefix(line, "hardware ethernet ") { - macString := strings.TrimSuffix(strings.TrimPrefix(line, "hardware ethernet "), ";") - parsed, macErr := net.ParseMAC(macString) - if macErr == nil { - currentMAC = parsed - } - } else if strings.HasPrefix(line, "client-hostname ") { - hostname := strings.TrimSuffix(strings.TrimPrefix(line, "client-hostname "), ";") - hostname = strings.Trim(hostname, "\"") - if hostname != "" { - currentHostname = hostname - } - } else if strings.HasPrefix(line, "binding state ") { - state := strings.TrimSuffix(strings.TrimPrefix(line, "binding state "), ";") - currentActive = state == "active" - } - } -} - -func (r *neighborResolver) parseKeaCSV4(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { - scanner := bufio.NewScanner(file) - firstLine := true - for scanner.Scan() { - if firstLine { - firstLine = false - continue - } - fields := strings.Split(scanner.Text(), ",") - if len(fields) < 10 { - continue - } - if fields[9] != "0" { - continue - } - address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) - if !addrOK { - continue - } - address = address.Unmap() - mac, macErr := net.ParseMAC(fields[1]) - if macErr != nil { - continue - } - ipToMAC[address] = mac - hostname := "" - if len(fields) > 8 { - hostname = fields[8] - } - if hostname != "" { - ipToHostname[address] = hostname - macToHostname[mac.String()] = hostname - } - } -} - -func (r *neighborResolver) parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { - scanner := bufio.NewScanner(file) - firstLine := true - for scanner.Scan() { - if firstLine { - firstLine = false - continue - } - fields := strings.Split(scanner.Text(), ",") - if len(fields) < 14 { - continue - } - if fields[13] != "0" { - continue - } - address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) - if !addrOK { - continue - } - address = address.Unmap() - var mac net.HardwareAddr - if fields[12] != "" { - mac, _ = net.ParseMAC(fields[12]) - } - if mac == nil { - duid, duidErr := hex.DecodeString(strings.ReplaceAll(fields[1], ":", "")) - if duidErr == nil { - mac, _ = extractMACFromDUID(duid) - } - } - hostname := "" - if len(fields) > 11 { - hostname = fields[11] - } - if mac != nil { - ipToMAC[address] = mac - } - if hostname != "" { - ipToHostname[address] = hostname - if mac != nil { - macToHostname[mac.String()] = hostname - } - } - } -} diff --git a/route/neighbor_resolver_stub.go b/route/neighbor_resolver_stub.go index 9288892a8d..177a1fccbc 100644 --- a/route/neighbor_resolver_stub.go +++ b/route/neighbor_resolver_stub.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux && !darwin package route diff --git a/route/neighbor_table_darwin.go b/route/neighbor_table_darwin.go new file mode 100644 index 0000000000..8ca2d0f0b7 --- /dev/null +++ b/route/neighbor_table_darwin.go @@ -0,0 +1,104 @@ +//go:build darwin + +package route + +import ( + "net" + "net/netip" + "syscall" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func ReadNeighborEntries() ([]adapter.NeighborEntry, error) { + var entries []adapter.NeighborEntry + ipv4Entries, err := readNeighborEntriesAF(syscall.AF_INET) + if err != nil { + return nil, E.Cause(err, "read IPv4 neighbors") + } + entries = append(entries, ipv4Entries...) + ipv6Entries, err := readNeighborEntriesAF(syscall.AF_INET6) + if err != nil { + return nil, E.Cause(err, "read IPv6 neighbors") + } + entries = append(entries, ipv6Entries...) + return entries, nil +} + +func readNeighborEntriesAF(addressFamily int) ([]adapter.NeighborEntry, error) { + rib, err := route.FetchRIB(addressFamily, route.RIBType(syscall.NET_RT_FLAGS), syscall.RTF_LLINFO) + if err != nil { + return nil, err + } + messages, err := route.ParseRIB(route.RIBType(syscall.NET_RT_FLAGS), rib) + if err != nil { + return nil, err + } + var entries []adapter.NeighborEntry + for _, message := range messages { + routeMessage, isRouteMessage := message.(*route.RouteMessage) + if !isRouteMessage { + continue + } + address, macAddress, ok := parseRouteNeighborEntry(routeMessage) + if !ok { + continue + } + entries = append(entries, adapter.NeighborEntry{ + Address: address, + MACAddress: macAddress, + }) + } + return entries, nil +} + +func parseRouteNeighborEntry(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, ok bool) { + if len(message.Addrs) <= unix.RTAX_GATEWAY { + return + } + gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr) + if !isLinkAddr || len(gateway.Addr) < 6 { + return + } + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + address = netip.AddrFrom4(destination.IP) + case *route.Inet6Addr: + address = netip.AddrFrom16(destination.IP) + default: + return + } + macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr))) + copy(macAddress, gateway.Addr) + ok = true + return +} + +func ParseRouteNeighborMessage(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) { + isDelete = message.Type == unix.RTM_DELETE + if len(message.Addrs) <= unix.RTAX_GATEWAY { + return + } + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + address = netip.AddrFrom4(destination.IP) + case *route.Inet6Addr: + address = netip.AddrFrom16(destination.IP) + default: + return + } + if !isDelete { + gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr) + if !isLinkAddr || len(gateway.Addr) < 6 { + return + } + macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr))) + copy(macAddress, gateway.Addr) + } + ok = true + return +} diff --git a/route/router.go b/route/router.go index 59eded3157..c141581d01 100644 --- a/route/router.go +++ b/route/router.go @@ -159,8 +159,7 @@ func (r *Router) Start(stage adapter.StartStage) error { } else { r.neighborResolver = resolver } - } - if r.neighborResolver == nil { + } else { monitor.Start("initialize neighbor resolver") resolver, err := newNeighborResolver(r.logger, r.leaseFiles) monitor.Finish() From 8280a9be138ce35a752ad2c19e22780ad80d843a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 6 Mar 2026 21:43:21 +0800 Subject: [PATCH 04/57] documentation: Update descriptions for neighbor rules --- docs/configuration/dns/rule.md | 4 +- docs/configuration/dns/rule.zh.md | 4 +- docs/configuration/route/index.md | 17 ++++++-- docs/configuration/route/index.zh.md | 17 ++++++-- docs/configuration/route/rule.md | 4 +- docs/configuration/route/rule.zh.md | 4 +- docs/configuration/shared/neighbor.md | 49 ++++++++++++++++++++++++ docs/configuration/shared/neighbor.zh.md | 49 ++++++++++++++++++++++++ mkdocs.yml | 1 + 9 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 docs/configuration/shared/neighbor.md create mode 100644 docs/configuration/shared/neighbor.zh.md diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 262a23e629..97a4a7b3d5 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -425,7 +425,7 @@ Match default interface address. !!! quote "" - Only supported on Linux with `route.find_neighbor` enabled. + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. Match source device MAC address. @@ -435,7 +435,7 @@ Match source device MAC address. !!! quote "" - Only supported on Linux with `route.find_neighbor` enabled. + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. Match source device hostname from DHCP leases. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 4bf60b9862..e1288bb69e 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -424,7 +424,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. !!! quote "" - 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 匹配源设备 MAC 地址。 @@ -434,7 +434,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. !!! quote "" - 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 匹配源设备从 DHCP 租约获取的主机名。 diff --git a/docs/configuration/route/index.md b/docs/configuration/route/index.md index 01e405614e..40104b619e 100644 --- a/docs/configuration/route/index.md +++ b/docs/configuration/route/index.md @@ -40,6 +40,7 @@ icon: material/alert-decagram "override_android_vpn": false, "default_interface": "", "default_mark": 0, + "find_process": false, "find_neighbor": false, "dhcp_lease_files": [], "default_domain_resolver": "", // or {} @@ -114,17 +115,25 @@ Set routing mark by default. Takes no effect if `outbound.routing_mark` is set. +#### find_process + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Enable process search for logging when no `process_name`, `process_path`, `package_name`, `user` or `user_id` rules exist. + #### find_neighbor !!! question "Since sing-box 1.14.0" !!! quote "" - Only supported on Linux. + Only supported on Linux and macOS. -Enable neighbor resolution for source MAC address and hostname lookup. +Enable neighbor resolution for logging when no `source_mac_address` or `source_hostname` rules exist. -Required for `source_mac_address` and `source_hostname` rule items. +See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. #### dhcp_lease_files @@ -132,7 +141,7 @@ Required for `source_mac_address` and `source_hostname` rule items. !!! quote "" - Only supported on Linux. + Only supported on Linux and macOS. Custom DHCP lease file paths for hostname and MAC address resolution. diff --git a/docs/configuration/route/index.zh.md b/docs/configuration/route/index.zh.md index 84ce76723c..518830b835 100644 --- a/docs/configuration/route/index.zh.md +++ b/docs/configuration/route/index.zh.md @@ -42,6 +42,7 @@ icon: material/alert-decagram "override_android_vpn": false, "default_interface": "", "default_mark": 0, + "find_process": false, "find_neighbor": false, "dhcp_lease_files": [], "default_network_strategy": "", @@ -113,17 +114,25 @@ icon: material/alert-decagram 如果设置了 `outbound.routing_mark` 设置,则不生效。 +#### find_process + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +在没有 `process_name`、`process_path`、`package_name`、`user` 或 `user_id` 规则时启用进程搜索以输出日志。 + #### find_neighbor !!! question "自 sing-box 1.14.0 起" !!! quote "" - 仅支持 Linux。 + 仅支持 Linux 和 macOS。 -启用邻居解析以查找源 MAC 地址和主机名。 +在没有 `source_mac_address` 或 `source_hostname` 规则时启用邻居解析以输出日志。 -`source_mac_address` 和 `source_hostname` 规则项需要此选项。 +参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 #### dhcp_lease_files @@ -131,7 +140,7 @@ icon: material/alert-decagram !!! quote "" - 仅支持 Linux。 + 仅支持 Linux 和 macOS。 用于主机名和 MAC 地址解析的自定义 DHCP 租约文件路径。 diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index d226571096..767e9ef756 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -466,7 +466,7 @@ Match specified outbounds' preferred routes. !!! quote "" - Only supported on Linux with `route.find_neighbor` enabled. + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. Match source device MAC address. @@ -476,7 +476,7 @@ Match source device MAC address. !!! quote "" - Only supported on Linux with `route.find_neighbor` enabled. + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. Match source device hostname from DHCP leases. diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 597e655f6e..e581ae995d 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -463,7 +463,7 @@ icon: material/new-box !!! quote "" - 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 匹配源设备 MAC 地址。 @@ -473,7 +473,7 @@ icon: material/new-box !!! quote "" - 仅支持 Linux,且需要 `route.find_neighbor` 已启用。 + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 匹配源设备从 DHCP 租约获取的主机名。 diff --git a/docs/configuration/shared/neighbor.md b/docs/configuration/shared/neighbor.md new file mode 100644 index 0000000000..c67d995ebe --- /dev/null +++ b/docs/configuration/shared/neighbor.md @@ -0,0 +1,49 @@ +--- +icon: material/lan +--- + +# Neighbor Resolution + +Match LAN devices by MAC address and hostname using +[`source_mac_address`](/configuration/route/rule/#source_mac_address) and +[`source_hostname`](/configuration/route/rule/#source_hostname) rule items. + +Neighbor resolution is automatically enabled when these rule items exist. +Use [`route.find_neighbor`](/configuration/route/#find_neighbor) to force enable it for logging without rules. + +## Linux + +Works natively. No special setup required. + +Hostname resolution requires DHCP lease files, +automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea). +Custom paths can be set via [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files). + +## Android + +!!! quote "" + + Only supported in graphical clients. + +Requires Android 11 or above and ROOT. + +Must use [VPNHotspot](https://github.com/Mygod/VPNHotspot) to share the VPN connection. +ROM built-in features like "Use VPN for connected devices" can share VPN +but cannot provide MAC address or hostname information. + +Set **IP Masquerade Mode** to **None** in VPNHotspot settings. + +Only route/DNS rules are supported. TUN include/exclude routes are not supported. + +### Hostname Visibility + +Hostname is only visible in sing-box if it is visible in VPNHotspot. +For Apple devices, change **Private Wi-Fi Address** from **Rotating** to **Fixed** in the Wi-Fi settings +of the connected network. Non-Apple devices are always visible. + +## macOS + +Requires the standalone version (macOS system extension). +The App Store version can share the VPN as a hotspot but does not support MAC address or hostname reading. + +See [VPN Hotspot](/manual/misc/vpn-hotspot/#macos) for Internet Sharing setup. diff --git a/docs/configuration/shared/neighbor.zh.md b/docs/configuration/shared/neighbor.zh.md new file mode 100644 index 0000000000..96297fcb57 --- /dev/null +++ b/docs/configuration/shared/neighbor.zh.md @@ -0,0 +1,49 @@ +--- +icon: material/lan +--- + +# 邻居解析 + +通过 +[`source_mac_address`](/configuration/route/rule/#source_mac_address) 和 +[`source_hostname`](/configuration/route/rule/#source_hostname) 规则项匹配局域网设备的 MAC 地址和主机名。 + +当这些规则项存在时,邻居解析自动启用。 +使用 [`route.find_neighbor`](/configuration/route/#find_neighbor) 可在没有规则时强制启用以输出日志。 + +## Linux + +原生支持,无需特殊设置。 + +主机名解析需要 DHCP 租约文件, +自动从常见 DHCP 服务器(dnsmasq、odhcpd、ISC dhcpd、Kea)检测。 +可通过 [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files) 设置自定义路径。 + +## Android + +!!! quote "" + + 仅在图形客户端中支持。 + +需要 Android 11 或以上版本和 ROOT。 + +必须使用 [VPNHotspot](https://github.com/Mygod/VPNHotspot) 共享 VPN 连接。 +ROM 自带的「通过 VPN 共享连接」等功能可以共享 VPN, +但无法提供 MAC 地址或主机名信息。 + +在 VPNHotspot 设置中将 **IP 遮掩模式** 设为 **无**。 + +仅支持路由/DNS 规则。不支持 TUN 的 include/exclude 路由。 + +### 设备可见性 + +MAC 地址和主机名仅在 VPNHotspot 中可见时 sing-box 才能读取。 +对于 Apple 设备,需要在所连接网络的 Wi-Fi 设置中将**私有无线局域网地址**从**轮替**改为**固定**。 +非 Apple 设备始终可见。 + +## macOS + +需要独立版本(macOS 系统扩展)。 +App Store 版本可以共享 VPN 热点但不支持 MAC 地址或主机名读取。 + +参阅 [VPN 热点](/manual/misc/vpn-hotspot/#macos) 了解互联网共享设置。 diff --git a/mkdocs.yml b/mkdocs.yml index 081ba3aa18..70edfaac43 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -129,6 +129,7 @@ nav: - UDP over TCP: configuration/shared/udp-over-tcp.md - TCP Brutal: configuration/shared/tcp-brutal.md - Wi-Fi State: configuration/shared/wifi-state.md + - Neighbor Resolution: configuration/shared/neighbor.md - Endpoint: - configuration/endpoint/index.md - WireGuard: configuration/endpoint/wireguard.md From 1b828083a1c1b7ec7d16ddb80f115f88688fd179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 7 Mar 2026 16:45:25 +0800 Subject: [PATCH 05/57] cronet-go: Update chromium to 145.0.7632.159 --- .github/CRONET_GO_VERSION | 2 +- go.mod | 62 +++++++++---------- go.sum | 124 +++++++++++++++++++------------------- 3 files changed, 94 insertions(+), 94 deletions(-) diff --git a/.github/CRONET_GO_VERSION b/.github/CRONET_GO_VERSION index 2838ee072b..cc20e651e7 100644 --- a/.github/CRONET_GO_VERSION +++ b/.github/CRONET_GO_VERSION @@ -1 +1 @@ -cba7b9ac0399055aa49fbdc57c03c374f58e1597 +d181863d6a4aa2e7bb7eaf67c1d512c5e4827fde diff --git a/go.mod b/go.mod index af891de1cf..a0b60fd8bc 100644 --- a/go.mod +++ b/go.mod @@ -29,8 +29,8 @@ require ( github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 - github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399 - github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399 + github.com/sagernet/cronet-go v0.0.0-20260306075351-e5943141aa40 + github.com/sagernet/cronet-go/all v0.0.0-20260306075351-e5943141aa40 github.com/sagernet/fswatch v0.1.1 github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 @@ -105,35 +105,35 @@ require ( github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/go.sum b/go.sum index d5634c3651..6f5c06f8af 100644 --- a/go.sum +++ b/go.sum @@ -162,68 +162,68 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399 h1:x3tVYQHdqqnKbEd9/H4KIGhtHTjA+KfiiaXedI3/w8Q= -github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= -github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399 h1:mD3ehudpYf1IFgCTv25d/B6KnBc/lLFq1jmSQIK24y0= -github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399/go.mod h1:MbYagcGGIaRo9tNrgafbCTO+Qc7eVEh32ZWMprSB8b0= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6 h1:ghRKgSaswefPwQF8AYtUlNyumILOB0ptJWxgZ8MFrEE= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:Behr7YCnQP2dsvzAJDIoMd5nTVU9/d6MMtk/S3MctwA= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6 h1:6UL9XdGU/44oTHj36e+EBDJ0RonFoObmd299NG/qQCU= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:Q9apxjtkj6iMIBQlTo71QsOTrNlhHneaXQb1Q0IshU8= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:0N+xlnMkFEeqgFe3X/PEvHt+/t+BPgxmbx7wzNcYppg= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:7f2vTXtePikBSV1bdD0zs5/WuZM+bRuej3mREpWL/qQ= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:HMlnhEYs+axOa0tAJ79se3QsYB8CpRCQo9mewWWFeeg= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:Ux/U6vF+1AoGLSJK3jVa9Kqkn64MX4Ivv7fy0ikDrpQ= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:5Dhuere2bQFzfGvKxA7TFgA5MoTtgcZMmJQuKwQKlyA= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6 h1:aMRcLow4UpZWZ28fR9FjveTL/4okrigZySIkEVZnlgA= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6 h1:y4g8oNtEfSdcKrBKsH5vMAjzGthvhHFNU80sanYDQEM= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:CXN6OPILi5trwffmYiiJ9rqJL3XAWx1menLrBBwA0gU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:ZphFHQeFOTpqCWPwFcQRnrePXajml8LbKlYFJ5n0isU= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6 h1:nKzFK84oANHz7I6bab+25bBY+pdpAbO0b3NJroyLldo= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:HqqZUGRXcWvvwlbuvjk/efo8TKW1H/aHdqQTde+Xs9Q= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:D2v9lZZG5sm4x/CkG7uqc6ZU3YlhFQ+GmJfvZMK0h/s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6 h1:TWveNeXHrA5r8XOlf+vw7U2b2M0ip6GNF89jcUi1ogw= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6 h1:DVCBoXOZI4PNG0cbCLg8lrphRXoLFcAIDLNmzsCVg3I= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:7s5xqNlBUWkIXdruPYi3/txXekQhGWxrYxbnB0cnARo= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6 h1:eyEb+Q7VH4hpE1nV+EmEnN2XX5WilgBpIsfCw4C/7no= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6 h1:9F1W7+z1hHST6GSzdpQ8Q0NCkneAL18dkRA1HfxH09A= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6 h1:MmQIR3iJsdvw1ONBP3geK57i9c3+v9dXPMNdZYcYGKw= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6 h1:j6Pk1Wsl+PCbKRXtp7a912D2D6zqX5Nk51wDQU9TEDc= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:0DnFhbRfNqwguNCxiinA7BowQ/RaFt627sjW09JNp80= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:3CZmlEk2/WW5UHLFJZxXPJ9IJxX3td8U3PyqWSGMl3c= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:eHkVRptoZf3BuuskkjcclO2dwQrX4zluoVGODMrX7n0= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:UgFmE0cZo9euu8/7sTAhj1G8lldavwXBdcPNyTE29CQ= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:xbg3ZB9tLMGDQe4+aewG0Z4bEP/2pLtYBcDzILv5eEc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:M0bTSTSTnSMlPY2WaZT6fL5TFICqk8v4cm+QVf8Fcao= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go v0.0.0-20260306075351-e5943141aa40 h1:A9P5YN0Tq+quO9vISIOL+PkExbGWAroyNIk9pI309ls= +github.com/sagernet/cronet-go v0.0.0-20260306075351-e5943141aa40/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go/all v0.0.0-20260306075351-e5943141aa40 h1:0W9yjyRZ/9peX7jFlruJgOhydBzqj0u7uRY+NUFlbCE= +github.com/sagernet/cronet-go/all v0.0.0-20260306075351-e5943141aa40/go.mod h1:U54HWP2v0xDyTEpAcof98Y923Lr1ymOvFWpa8aVBBAk= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260306074725-2e4f95b376d3 h1:Par4t1sZVTJodVxVoGoaSi4MTojaDrraHXCK5Xjt/rM= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:Wg7qunP2EtGnQSHaAL2a/shion6Y5QatyFtAoMcZjdg= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260306074725-2e4f95b376d3 h1:JZSGrRe1y5yR+REJLK2X1ZxHcUnXc110m7rEuqkhurk= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:DwgYmuUd36tXSJuu3wK1HntOifcRPifDc/s6X6LdVSQ= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:jjjSy31cytxMRYLoNlwA98YasRAe0P5EEsw5c4Pwvv0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:2b/N8xhl+MBRIg70sHYuJ/3V3gJu3F4aVTndxFnbICU= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:CysHa5F+LqLumG3HUfUbQzWIbG13QMTUMkkc2DTHclU= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:Lf9FtR/87jNgc+0yeCCxlvlu2RLSrlaaYfVlYCJeFq0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:1X6PNucfXzZB21EOP0aBn+m06UgL6e4oJZJ2bcqrbtM= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260306074725-2e4f95b376d3 h1:XvHeLlblB6nXilTqfDI+SxyIuR2FUkpNkL9mXNt/wNg= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260306074725-2e4f95b376d3 h1:ACHr8UvOHs/+S29L7UcCrTe3P53NuZbKzHmwCpteyoo= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:MzSFaCUaGn/a4jAGw7Qnm0t5ssnx1z87YEqwvG1ZhRU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:i7lFKCd4AcKut4Co/jEzvb9d1d10K3t4un9NarqAyo0= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260306074725-2e4f95b376d3 h1:aLBHE3UGmBf+f+Vf5ceYDzsKPufDfYoMILrMhqwsJYI= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:5CZoDiP1u3REF7LcBYoQgBuWacnBcxWeERU5UrQDqHg= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:4W7D6UUZH5/636fE2VMHJ+YLofmYWaBhAlvaj23C20I= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260306074725-2e4f95b376d3 h1:zK/9ebQ3Ykcvomc+JEIou8rgIxbU1O6bBB7z2A3irO4= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260306074725-2e4f95b376d3 h1:q79ByUHlbxPcADvOZ2G8ayCnLBlF/fzHtvLennf2clo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:3RNNwgX1rltXu7gIGD12gxlIJc1s8e2stB2BzMtl9tE= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260306074725-2e4f95b376d3 h1:/hD/Vk7/Jlg07Ic1atNjU1mXii91ziN6e3zxFYTKqio= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260306074725-2e4f95b376d3 h1:d7Z63bQ/U7ZmB1MkC1dtAtIn6h40WrHey9S/vnfDb5g= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260306074725-2e4f95b376d3 h1:1c6ZqstM62BrbTFrCA4vINFTCooCM8uph6uIGfAEfqQ= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260306074725-2e4f95b376d3 h1:ZTHDXreHG+9XT0hD+MIu1etqPQAfKBApFS8Z1XMT7Nw= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:ry0S9V5pSNTg2wXra1rBajSITvXRufgw0u3w/mE0GB4= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:iO5cm5MiqvKQB7QkY2b8QFgnMt3jDdOiDopX2aNsFOM= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:DC03qT5UTbDgUzJ78xajYXq5UYcFHBLHKIoH+PRpCf0= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:uLqZSA2OAynMxrokxVO2pW3unWA8DNjion/I4ihX/84= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:WHTBryhjXaniv5fMjSr/FvWKyAhdomD7rLagh4ano10= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:TmikX4Xtalpv2Jts/MuB5qwg+KmTKbrpPf5deZGLIqA= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg= From 7f2316aff0cc0c1a200f3fae192b064865b3c97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 7 Mar 2026 16:40:34 +0800 Subject: [PATCH 06/57] Bump version --- docs/changelog.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 29c4860597..6c3d8759c2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,31 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.1 + +* Add `source_mac_address` and `source_hostname` rule items **1** +* Add `include_mac_address` and `exclude_mac_address` TUN options **2** +* Update NaiveProxy to 145.0.7632.159 **3** +* Fixes and improvements + +**1**: + +New rule items for matching LAN devices by MAC address and hostname via neighbor resolution. +Supported on Linux, macOS, or in graphical clients on Android and macOS. + +See [Route Rule](/configuration/route/rule/#source_mac_address), [DNS Rule](/configuration/dns/rule/#source_mac_address) and [Neighbor Resolution](/configuration/shared/neighbor/). + +**2**: + +Limit or exclude devices from TUN routing by MAC address. +Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +See [TUN](/configuration/inbound/tun/#include_mac_address). + +**3**: + +This is not an official update from NaiveProxy. Instead, it's a Chromium codebase update maintained by Project S. + #### 1.13.2 * Fixes and improvements From 00b0bdb2bbf9f12973787fa938b8fa426da0475f Mon Sep 17 00:00:00 2001 From: reF1nd Date: Tue, 3 Mar 2026 23:23:05 +0800 Subject: [PATCH 07/57] Apply legacy transport strategy for default DNS transport in matchDNS --- dns/router.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/dns/router.go b/dns/router.go index 567f3225f4..98913fad61 100644 --- a/dns/router.go +++ b/dns/router.go @@ -195,7 +195,16 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } } } - return r.transport.Default(), nil, -1 + defaultTransport := r.transport.Default() + if legacyTransport, isLegacy := defaultTransport.(adapter.LegacyDNSTransport); isLegacy { + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = legacyTransport.LegacyStrategy() + } + if !options.ClientSubnet.IsValid() { + options.ClientSubnet = legacyTransport.LegacyClientSubnet() + } + } + return defaultTransport, nil, -1 } func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { From 90bf604226a58fefa47954ed1bfbe9d35fefea59 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Sat, 11 Oct 2025 23:07:08 +0800 Subject: [PATCH 08/57] Revert "Disable TCP slow open for anytls" --- protocol/anytls/outbound.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 2f24c2ef8f..fa5fe2419c 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -13,7 +13,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" - 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/common/uot" @@ -44,13 +43,6 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } - // TCP Fast Open is incompatible with anytls because TFO creates a lazy connection - // that only establishes on first write. The lazy connection returns an empty address - // before establishment, but anytls SOCKS wrapper tries to access the remote address - // during handshake, causing a null pointer dereference crash. - if options.DialerOptions.TCPFastOpen { - return nil, E.New("tcp_fast_open is not supported with anytls outbound") - } tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) if err != nil { From f21e6a209fa5211d9f79fd697311dc37e1991405 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 19 Mar 2025 17:42:39 +0800 Subject: [PATCH 09/57] Restore proxy protocol --- common/listener/listener_tcp.go | 8 +-- common/proxyproto/dialer.go | 50 ++++++++++++++++ common/proxyproto/listener.go | 63 ++++++++++++++++++++ docs/configuration/outbound/direct.md | 5 ++ docs/configuration/outbound/direct.zh.md | 7 +++ docs/configuration/shared/listen.md | 8 +++ docs/configuration/shared/listen.zh.md | 8 +++ go.mod | 2 +- option/direct.go | 3 +- option/inbound.go | 37 ++++++------ protocol/direct/outbound.go | 75 ++++++++++++++++++++++-- 11 files changed, 233 insertions(+), 33 deletions(-) create mode 100644 common/proxyproto/dialer.go create mode 100644 common/proxyproto/listener.go diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index 899d444fea..d2653af2f9 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/proxyproto" "github.com/sagernet/sing-box/common/redir" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -21,10 +22,6 @@ import ( ) func (l *Listener) ListenTCP() (net.Listener, error) { - //nolint:staticcheck - if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { - return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") - } var err error bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) var listenConfig net.ListenConfig @@ -74,6 +71,9 @@ func (l *Listener) ListenTCP() (net.Listener, error) { if err != nil { return nil, err } + if l.listenOptions.ProxyProtocol { + tcpListener = &proxyproto.Listener{Listener: tcpListener, AcceptNoHeader: l.listenOptions.ProxyProtocolAcceptNoHeader} + } l.logger.Info("tcp server started at ", tcpListener.Addr()) l.tcpListener = tcpListener return tcpListener, err diff --git a/common/proxyproto/dialer.go b/common/proxyproto/dialer.go new file mode 100644 index 0000000000..f3fba6f4da --- /dev/null +++ b/common/proxyproto/dialer.go @@ -0,0 +1,50 @@ +package proxyproto + +import ( + "context" + "net" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/pires/go-proxyproto" +) + +var _ N.Dialer = (*Dialer)(nil) + +type Dialer struct { + N.Dialer +} + +func (d *Dialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + conn, err := d.Dialer.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + var source M.Socksaddr + metadata := adapter.ContextFrom(ctx) + if metadata != nil { + source = metadata.Source + } + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if destination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + h := proxyproto.HeaderProxyFromAddrs(1, source.TCPAddr(), destination.TCPAddr()) + _, err = h.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + return conn, nil + default: + return d.Dialer.DialContext(ctx, network, destination) + } +} diff --git a/common/proxyproto/listener.go b/common/proxyproto/listener.go new file mode 100644 index 0000000000..c70d500121 --- /dev/null +++ b/common/proxyproto/listener.go @@ -0,0 +1,63 @@ +package proxyproto + +import ( + std_bufio "bufio" + "net" + + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + + "github.com/pires/go-proxyproto" +) + +type Listener struct { + net.Listener + AcceptNoHeader bool +} + +func (l *Listener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + bufReader := std_bufio.NewReader(conn) + header, err := proxyproto.Read(bufReader) + if err != nil && !(l.AcceptNoHeader && err == proxyproto.ErrNoProxyProtocol) { + return nil, &Error{err} + } + if bufReader.Buffered() > 0 { + cache := buf.NewSize(bufReader.Buffered()) + _, err = cache.ReadFullFrom(bufReader, cache.FreeLen()) + if err != nil { + return nil, &Error{err} + } + conn = bufio.NewCachedConn(conn, cache) + } + if header != nil { + return &bufio.AddrConn{ + Conn: conn, + Source: M.SocksaddrFromNet(header.SourceAddr).Unwrap(), + Destination: M.SocksaddrFromNet(header.DestinationAddr).Unwrap(), + }, nil + } + return conn, nil +} + +var _ net.Error = (*Error)(nil) + +type Error struct { + error +} + +func (e *Error) Unwrap() error { + return e.error +} + +func (e *Error) Timeout() bool { + return false +} + +func (e *Error) Temporary() bool { + return true +} diff --git a/docs/configuration/outbound/direct.md b/docs/configuration/outbound/direct.md index 3e28db8fc6..071489445c 100644 --- a/docs/configuration/outbound/direct.md +++ b/docs/configuration/outbound/direct.md @@ -18,6 +18,7 @@ icon: material/alert-decagram "override_address": "1.0.0.1", "override_port": 53, + "proxy_protocol": 0, ... // Dial Fields } @@ -41,6 +42,10 @@ Override the connection destination address. Override the connection destination port. +#### proxy_protocol + +Write [Proxy Protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) in the connection header. + Protocol value can be `1` or `2`. ### Dial Fields diff --git a/docs/configuration/outbound/direct.zh.md b/docs/configuration/outbound/direct.zh.md index 55d3bf8c2d..99dda473da 100644 --- a/docs/configuration/outbound/direct.zh.md +++ b/docs/configuration/outbound/direct.zh.md @@ -18,6 +18,7 @@ icon: material/alert-decagram "override_address": "1.0.0.1", "override_port": 53, + "proxy_protocol": 0, ... // 拨号字段 } @@ -41,6 +42,12 @@ icon: material/alert-decagram 覆盖连接目标端口。 +#### proxy_protocol + +写出 [代理协议](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) 到连接头。 + +可用协议版本值:`1` 或 `2`。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/shared/listen.md b/docs/configuration/shared/listen.md index 55325564a4..fc64237142 100644 --- a/docs/configuration/shared/listen.md +++ b/docs/configuration/shared/listen.md @@ -200,3 +200,11 @@ the original packet address will be sent in the response instead of the mapped d This option is used for compatibility with clients that do not support receiving UDP packets with domain addresses, such as Surge. + +#### proxy_protocol + +Parse [Proxy Protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) in the connection header. + +#### proxy_protocol_accept_no_header + +Accept connections without Proxy Protocol header. \ No newline at end of file diff --git a/docs/configuration/shared/listen.zh.md b/docs/configuration/shared/listen.zh.md index 905cea3cd8..e4189f4e98 100644 --- a/docs/configuration/shared/listen.zh.md +++ b/docs/configuration/shared/listen.zh.md @@ -198,3 +198,11 @@ UDP NAT 过期时间。 如果启用,对于地址为域的 UDP 代理请求,将在响应中发送原始包地址而不是映射的域。 此选项用于兼容不支持接收带有域地址的 UDP 包的客户端,如 Surge。 + +#### proxy_protocol + +解析连接头中的 [代理协议](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt)。 + +#### proxy_protocol_accept_no_header + +接受没有代理协议标头的连接。 \ No newline at end of file diff --git a/go.mod b/go.mod index a0b60fd8bc..3c5cebdd48 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/miekg/dns v1.1.72 github.com/openai/openai-go/v3 v3.24.0 github.com/oschwald/maxminddb-golang v1.13.1 + github.com/pires/go-proxyproto v0.8.1 github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 @@ -100,7 +101,6 @@ require ( github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect diff --git a/option/direct.go b/option/direct.go index a03f98d412..950eb62633 100644 --- a/option/direct.go +++ b/option/direct.go @@ -16,12 +16,11 @@ type DirectInboundOptions struct { type _DirectOutboundOptions struct { DialerOptions + ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` // Deprecated: Use Route Action instead OverrideAddress string `json:"override_address,omitempty"` // Deprecated: Use Route Action instead OverridePort uint16 `json:"override_port,omitempty"` - // Deprecated: removed - ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` } type DirectOutboundOptions _DirectOutboundOptions diff --git a/option/inbound.go b/option/inbound.go index 4fb6081dc0..548d486d74 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -58,26 +58,23 @@ type InboundOptions struct { } type ListenOptions struct { - Listen *badoption.Addr `json:"listen,omitempty"` - ListenPort uint16 `json:"listen_port,omitempty"` - BindInterface string `json:"bind_interface,omitempty"` - RoutingMark FwMark `json:"routing_mark,omitempty"` - ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` - DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` - TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` - TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` - TCPFastOpen bool `json:"tcp_fast_open,omitempty"` - TCPMultiPath bool `json:"tcp_multi_path,omitempty"` - UDPFragment *bool `json:"udp_fragment,omitempty"` - UDPFragmentDefault bool `json:"-"` - UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` - Detour string `json:"detour,omitempty"` - - // Deprecated: removed - ProxyProtocol bool `json:"proxy_protocol,omitempty"` - // Deprecated: removed - ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` + Listen *badoption.Addr `json:"listen,omitempty"` + ListenPort uint16 `json:"listen_port,omitempty"` + BindInterface string `json:"bind_interface,omitempty"` + RoutingMark FwMark `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + NetNs string `json:"netns,omitempty"` + DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` + TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` + Detour string `json:"detour,omitempty"` + ProxyProtocol bool `json:"proxy_protocol,omitempty"` + ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` InboundOptions } diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index 9d24f31aff..dfeeeef93d 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -20,6 +20,8 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + + "github.com/pires/go-proxyproto" ) func RegisterOutbound(registry *outbound.Registry) { @@ -42,6 +44,7 @@ type Outbound struct { fallbackDelay time.Duration isEmpty bool // loopBack *loopBackDetector + proxyProto uint8 } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.DirectOutboundOptions) (adapter.Outbound, error) { @@ -68,16 +71,17 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL dialer: outboundDialer.(dialer.ParallelInterfaceDialer), isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), // loopBack: newLoopBackDetector(router), + proxyProto: options.ProxyProtocol, } - //nolint:staticcheck - if options.ProxyProtocol != 0 { - return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") + if options.ProxyProtocol > 2 { + return nil, E.New("invalid proxy protocol option: ", options.ProxyProtocol) } return outbound, nil } func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) + originDestination := metadata.Destination metadata.Outbound = h.Tag() metadata.Destination = destination network = N.NetworkName(network) @@ -92,7 +96,26 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination return nil, err } return h.loopBack.NewConn(conn), nil*/ - return h.dialer.DialContext(ctx, network, destination) + conn, err := h.dialer.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + if h.proxyProto > 0 { + source := metadata.Source + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if originDestination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + header := proxyproto.HeaderProxyFromAddrs(h.proxyProto, source.TCPAddr(), originDestination.TCPAddr()) + _, err = header.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + } + return conn, nil } func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -120,6 +143,7 @@ func (h *Outbound) NewDirectRouteConnection(metadata adapter.InboundContext, rou func (h *Outbound) DialParallel(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) + originDestination := metadata.Destination metadata.Outbound = h.Tag() metadata.Destination = destination network = N.NetworkName(network) @@ -129,11 +153,31 @@ func (h *Outbound) DialParallel(ctx context.Context, network string, destination case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), nil, nil, nil, h.fallbackDelay) + conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), nil, nil, nil, h.fallbackDelay) + if err != nil { + return nil, err + } + if h.proxyProto > 0 { + source := metadata.Source + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if originDestination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + header := proxyproto.HeaderProxyFromAddrs(h.proxyProto, source.TCPAddr(), originDestination.TCPAddr()) + _, err = header.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + } + return conn, nil } func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy *C.NetworkStrategy, networkType []C.InterfaceType, fallbackNetworkType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) + originDestination := metadata.Destination metadata.Outbound = h.Tag() metadata.Destination = destination network = N.NetworkName(network) @@ -143,7 +187,26 @@ func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, dest case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), networkStrategy, networkType, fallbackNetworkType, fallbackDelay) + conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), networkStrategy, networkType, fallbackNetworkType, fallbackDelay) + if err != nil { + return nil, err + } + if h.proxyProto > 0 { + source := metadata.Source + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if originDestination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + header := proxyproto.HeaderProxyFromAddrs(h.proxyProto, source.TCPAddr(), originDestination.TCPAddr()) + _, err = header.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + } + return conn, nil } func (h *Outbound) ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy *C.NetworkStrategy, networkType []C.InterfaceType, fallbackNetworkType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) { From c7b30920ffcbc5ae796095a9e7d0c74f1a3c8e37 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 6 Feb 2026 22:01:40 +0800 Subject: [PATCH 10/57] Add configurable `gso` option for WireGuard and Tailscale endpoint --- docs/configuration/endpoint/tailscale.md | 11 +++++++++++ docs/configuration/endpoint/tailscale.zh.md | 11 +++++++++++ docs/configuration/endpoint/wireguard.md | 11 +++++++++++ docs/configuration/endpoint/wireguard.zh.md | 11 +++++++++++ docs/configuration/outbound/wireguard.md | 15 +++------------ docs/configuration/outbound/wireguard.zh.md | 15 +++------------ option/tailscale.go | 1 + option/wireguard.go | 1 + protocol/tailscale/endpoint.go | 10 ++++++++-- protocol/wireguard/endpoint.go | 7 ++++++- transport/wireguard/device.go | 3 ++- transport/wireguard/device_system.go | 4 ++-- transport/wireguard/endpoint.go | 3 ++- transport/wireguard/endpoint_options.go | 3 ++- 14 files changed, 74 insertions(+), 32 deletions(-) diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index 6cf10e2ba9..2138b5709b 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -34,6 +34,7 @@ icon: material/new-box "relay_server_static_endpoints": [], "system_interface": false, "system_interface_name": "", + "system_interface_gso": false, "system_interface_mtu": 0, "udp_timeout": "5m", @@ -136,6 +137,16 @@ Create a system TUN interface for Tailscale. Custom TUN interface name. By default, `tailscale` (or `utun` on macOS) will be used. +#### system_interface_gso + +!!! quote "" + + Only supported on Linux. + +Try to enable generic segmentation offload. + +Enabled by default when `system_interface` is true. + #### system_interface_mtu !!! question "Since sing-box 1.13.0" diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index f881dd67f2..2bb007fafc 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -34,6 +34,7 @@ icon: material/new-box "relay_server_static_endpoints": [], "system_interface": false, "system_interface_name": "", + "system_interface_gso": false, "system_interface_mtu": 0, "udp_timeout": "5m", @@ -135,6 +136,16 @@ icon: material/new-box 自定义 TUN 接口名。默认使用 `tailscale`(macOS 上为 `utun`)。 +#### system_interface_gso + +!!! quote "" + + 仅支持 Linux。 + +尝试启用通用分段卸载。 + +当 `system_interface` 为 true 时,默认启用。 + #### system_interface_mtu !!! question "自 sing-box 1.13.0 起" diff --git a/docs/configuration/endpoint/wireguard.md b/docs/configuration/endpoint/wireguard.md index dc3b82289a..3cc1c08a99 100644 --- a/docs/configuration/endpoint/wireguard.md +++ b/docs/configuration/endpoint/wireguard.md @@ -9,6 +9,7 @@ "system": false, "name": "", + "gso": false, "mtu": 1408, "address": [], "private_key": "", @@ -47,6 +48,16 @@ Requires privilege and cannot conflict with exists system interfaces. Custom interface name for system interface. +#### gso + +!!! quote "" + + Only supported on Linux. + +Try to enable generic segmentation offload. + +Enabled by default when `system` is true. + #### mtu WireGuard MTU. diff --git a/docs/configuration/endpoint/wireguard.zh.md b/docs/configuration/endpoint/wireguard.zh.md index 1935135f87..04af65a2d7 100644 --- a/docs/configuration/endpoint/wireguard.zh.md +++ b/docs/configuration/endpoint/wireguard.zh.md @@ -9,6 +9,7 @@ "system": false, "name": "", + "gso": false, "mtu": 1408, "address": [], "private_key": "", @@ -47,6 +48,16 @@ 为系统接口自定义设备名称。 +#### gso + +!!! quote "" + + 仅支持 Linux。 + +尝试启用通用分段卸载。 + +当 `system` 为 true 时,默认启用。 + #### mtu WireGuard MTU。 diff --git a/docs/configuration/outbound/wireguard.md b/docs/configuration/outbound/wireguard.md index 648ba60725..52ffb3953b 100644 --- a/docs/configuration/outbound/wireguard.md +++ b/docs/configuration/outbound/wireguard.md @@ -6,10 +6,6 @@ icon: material/delete-clock WireGuard outbound is deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-wireguard-outbound-to-endpoint). -!!! quote "Changes in sing-box 1.11.0" - - :material-delete-alert: [gso](#gso) - !!! quote "Changes in sing-box 1.8.0" :material-plus: [gso](#gso) @@ -25,6 +21,7 @@ icon: material/delete-clock "server_port": 1080, "system_interface": false, "interface_name": "wg0", + "gso": false, "local_address": [ "10.0.0.1/32" ], @@ -48,10 +45,6 @@ icon: material/delete-clock "mtu": 1408, "network": "tcp", - // Deprecated - - "gso": false, - ... // Dial Fields } ``` @@ -84,10 +77,6 @@ Custom interface name for system interface. #### gso -!!! failure "Deprecated in sing-box 1.11.0" - - GSO will be automatically enabled when available since sing-box 1.11.0. - !!! question "Since sing-box 1.8.0" !!! quote "" @@ -96,6 +85,8 @@ Custom interface name for system interface. Try to enable generic segmentation offload. +Enabled by default when `system_interface` is true. + #### local_address ==Required== diff --git a/docs/configuration/outbound/wireguard.zh.md b/docs/configuration/outbound/wireguard.zh.md index 3b22affd4c..ef7c726e34 100644 --- a/docs/configuration/outbound/wireguard.zh.md +++ b/docs/configuration/outbound/wireguard.zh.md @@ -6,10 +6,6 @@ icon: material/delete-clock WireGuard 出站已被弃用,且将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/migration/#migrate-wireguard-outbound-to-endpoint)。 -!!! quote "sing-box 1.11.0 中的更改" - - :material-delete-alert: [gso](#gso) - !!! quote "sing-box 1.8.0 中的更改" :material-plus: [gso](#gso) @@ -25,6 +21,7 @@ icon: material/delete-clock "server_port": 1080, "system_interface": false, "interface_name": "wg0", + "gso": false, "local_address": [ "10.0.0.1/32" ], @@ -35,10 +32,6 @@ icon: material/delete-clock "workers": 4, "mtu": 1408, "network": "tcp", - - // 废弃的 - - "gso": false, ... // 拨号字段 } @@ -72,10 +65,6 @@ icon: material/delete-clock #### gso -!!! failure "已在 sing-box 1.11.0 废弃" - - 自 sing-box 1.11.0 起,GSO 将可用时自动启用。 - !!! question "自 sing-box 1.8.0 起" !!! quote "" @@ -84,6 +73,8 @@ icon: material/delete-clock 尝试启用通用分段卸载。 +当 `system_interface` 为 true 时,默认启用。 + #### local_address ==必填== diff --git a/option/tailscale.go b/option/tailscale.go index dac8e866a5..37525e36bf 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -27,6 +27,7 @@ type TailscaleEndpointOptions struct { RelayServerStaticEndpoints []netip.AddrPort `json:"relay_server_static_endpoints,omitempty"` SystemInterface bool `json:"system_interface,omitempty"` SystemInterfaceName string `json:"system_interface_name,omitempty"` + SystemInterfaceGSO *bool `json:"system_interface_gso,omitempty"` SystemInterfaceMTU uint32 `json:"system_interface_mtu,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` } diff --git a/option/wireguard.go b/option/wireguard.go index c86abd112a..c1490a84bc 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -8,6 +8,7 @@ import ( type WireGuardEndpointOptions struct { System bool `json:"system,omitempty"` + GSO *bool `json:"gso,omitempty"` Name string `json:"name,omitempty"` MTU uint32 `json:"mtu,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index ff82ef86e4..12236bbafa 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -29,7 +29,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" @@ -106,6 +106,7 @@ type Endpoint struct { systemInterface bool systemInterfaceName string + systemInterfaceGSO bool systemInterfaceMTU uint32 systemTun tun.Tun fallbackTCPCloser func() @@ -180,6 +181,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } else { udpTimeout = C.UDPTimeout } + gso := options.SystemInterface + if options.SystemInterfaceGSO != nil { + gso = *options.SystemInterfaceGSO + } var remoteIsDomain bool if options.ControlURL != "" { controlURL, err := url.Parse(options.ControlURL) @@ -252,6 +257,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL relayServerStaticEndpoints: options.RelayServerStaticEndpoints, udpTimeout: udpTimeout, systemInterface: options.SystemInterface, + systemInterfaceGSO: gso, systemInterfaceName: options.SystemInterfaceName, systemInterfaceMTU: options.SystemInterfaceMTU, }, nil @@ -301,7 +307,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { tunOptions := tun.Options{ Name: tunName, MTU: mtu, - GSO: true, + GSO: t.systemInterfaceGSO, InterfaceScope: true, InterfaceMonitor: t.network.InterfaceMonitor(), InterfaceFinder: t.network.InterfaceFinder(), diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index bcf2078eec..984b941d7b 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -14,7 +14,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route/rule" "github.com/sagernet/sing-box/transport/wireguard" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" @@ -72,10 +72,15 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } else { udpTimeout = C.UDPTimeout } + gso := options.System + if options.GSO != nil { + gso = *options.GSO + } wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ Context: ctx, Logger: logger, System: options.System, + GSO: gso, Handler: ep, UDPTimeout: udpTimeout, Dialer: outboundDialer, diff --git a/transport/wireguard/device.go b/transport/wireguard/device.go index 4dd615c585..a37e9c7cfa 100644 --- a/transport/wireguard/device.go +++ b/transport/wireguard/device.go @@ -6,7 +6,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/sagernet/wireguard-go/device" @@ -26,6 +26,7 @@ type DeviceOptions struct { Context context.Context Logger logger.ContextLogger System bool + GSO bool Handler tun.Handler UDPTimeout time.Duration CreateDialer func(interfaceName string) N.Dialer diff --git a/transport/wireguard/device_system.go b/transport/wireguard/device_system.go index dcf2959b63..c0a5aee32f 100644 --- a/transport/wireguard/device_system.go +++ b/transport/wireguard/device_system.go @@ -10,7 +10,7 @@ import ( "sync" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -91,7 +91,7 @@ func (w *systemDevice) Start() error { return it.Addr().Is6() }), MTU: w.options.MTU, - GSO: true, + GSO: w.options.GSO, InterfaceScope: true, Inet4RouteAddress: common.Filter(w.options.AllowedAddress, func(it netip.Prefix) bool { return it.Addr().Is4() diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index dac07c859c..83848ec5b5 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -15,7 +15,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -107,6 +107,7 @@ func NewEndpoint(options EndpointOptions) (*Endpoint, error) { Context: options.Context, Logger: options.Logger, System: options.System, + GSO: options.GSO, Handler: options.Handler, UDPTimeout: options.UDPTimeout, CreateDialer: options.CreateDialer, diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index bb9a46e69f..1bf239e6f5 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -5,7 +5,7 @@ import ( "net/netip" "time" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -15,6 +15,7 @@ type EndpointOptions struct { Context context.Context Logger logger.ContextLogger System bool + GSO bool Handler tun.Handler UDPTimeout time.Duration Dialer N.Dialer From d943e67a78c902581ceb71848e9a9e7a8b3ac35d Mon Sep 17 00:00:00 2001 From: reF1nd Date: Sun, 7 Sep 2025 14:43:12 +0800 Subject: [PATCH 11/57] Add `direct_domain_strategy` option to direct outbound --- option/direct.go | 3 +- protocol/direct/outbound.go | 62 +++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/option/direct.go b/option/direct.go index 950eb62633..59f18d62e8 100644 --- a/option/direct.go +++ b/option/direct.go @@ -16,7 +16,8 @@ type DirectInboundOptions struct { type _DirectOutboundOptions struct { DialerOptions - ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` + DirectDomainStrategy DomainStrategy `json:"direct_domain_strategy,omitempty"` + ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` // Deprecated: Use Route Action instead OverrideAddress string `json:"override_address,omitempty"` // Deprecated: Use Route Action instead diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index dfeeeef93d..07e70ef0c1 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -13,7 +13,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" @@ -37,12 +37,13 @@ var ( type Outbound struct { outbound.Adapter - ctx context.Context - logger logger.ContextLogger - dialer dialer.ParallelInterfaceDialer - domainStrategy C.DomainStrategy - fallbackDelay time.Duration - isEmpty bool + ctx context.Context + logger logger.ContextLogger + dialer dialer.ParallelInterfaceDialer + domainStrategy C.DomainStrategy + directDomainStrategy C.DomainStrategy + fallbackDelay time.Duration + isEmpty bool // loopBack *loopBackDetector proxyProto uint8 } @@ -66,10 +67,11 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL ctx: ctx, logger: logger, //nolint:staticcheck - domainStrategy: C.DomainStrategy(options.DomainStrategy), - fallbackDelay: time.Duration(options.FallbackDelay), - dialer: outboundDialer.(dialer.ParallelInterfaceDialer), - isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), + domainStrategy: C.DomainStrategy(options.DomainStrategy), + directDomainStrategy: C.DomainStrategy(options.DirectDomainStrategy), + fallbackDelay: time.Duration(options.FallbackDelay), + dialer: outboundDialer.(dialer.ParallelInterfaceDialer), + isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), // loopBack: newLoopBackDetector(router), proxyProto: options.ProxyProtocol, } @@ -153,7 +155,24 @@ func (h *Outbound) DialParallel(ctx context.Context, network string, destination case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), nil, nil, nil, h.fallbackDelay) + var preferIPv6 bool + switch h.directDomainStrategy { + case C.DomainStrategyAsIS: + preferIPv6 = len(destinationAddresses) > 0 && destinationAddresses[0].Is6() + case C.DomainStrategyIPv4Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is4) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv4 address available for ", destination) + } + case C.DomainStrategyIPv6Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is6) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv6 address available for ", destination) + } + case C.DomainStrategyPreferIPv6: + preferIPv6 = len(destinationAddresses) > 0 + } + conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, preferIPv6, nil, nil, nil, h.fallbackDelay) if err != nil { return nil, err } @@ -187,7 +206,24 @@ func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, dest case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), networkStrategy, networkType, fallbackNetworkType, fallbackDelay) + var preferIPv6 bool + switch h.directDomainStrategy { + case C.DomainStrategyAsIS: + preferIPv6 = len(destinationAddresses) > 0 && destinationAddresses[0].Is6() + case C.DomainStrategyIPv4Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is4) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv4 address available for ", destination) + } + case C.DomainStrategyIPv6Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is6) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv6 address available for ", destination) + } + case C.DomainStrategyPreferIPv6: + preferIPv6 = len(destinationAddresses) > 0 + } + conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, preferIPv6, networkStrategy, networkType, fallbackNetworkType, fallbackDelay) if err != nil { return nil, err } From 9f88d95f6f137fdaa65545d3b70166482acc0b7e Mon Sep 17 00:00:00 2001 From: reF1nd Date: Mon, 17 Mar 2025 17:50:54 +0800 Subject: [PATCH 12/57] Improve sniffer clash api: add sniffing message clash api: make metadata correct sniffer: use origin fqdn when destination is fqdn route: fix udp destination overriding nat type loger: add destination overriding log --- adapter/inbound.go | 5 +- common/sniff/http.go | 2 +- common/sniff/http_test.go | 4 +- common/sniff/quic.go | 2 +- common/sniff/quic_test.go | 10 ++-- common/sniff/tls.go | 2 +- experimental/clashapi/configs.go | 3 ++ .../clashapi/trafficontrol/tracker.go | 14 +++-- route/route.go | 52 ++++++++++--------- route/rule/rule_item_adguard.go | 8 +-- route/rule/rule_item_domain.go | 8 +-- route/rule/rule_item_domain_keyword.go | 8 +-- route/rule/rule_item_domain_regex.go | 8 +-- route/rule/rule_item_preferred_by.go | 8 +-- 14 files changed, 81 insertions(+), 53 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index acd6f4912c..e053540500 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -54,7 +54,7 @@ type InboundContext struct { // sniffer Protocol string - Domain string + SniffHost string Client string SniffContext any SnifferNames []string @@ -62,6 +62,8 @@ type InboundContext struct { // cache + Domain string + // Deprecated: implement in rule action InboundDetour string LastInbound string @@ -87,6 +89,7 @@ type InboundContext struct { SourceHostname string QueryType uint16 FakeIP bool + DestOverride bool // rule cache diff --git a/common/sniff/http.go b/common/sniff/http.go index 012f2c99df..9d3127c22d 100644 --- a/common/sniff/http.go +++ b/common/sniff/http.go @@ -23,6 +23,6 @@ func HTTPHost(_ context.Context, metadata *adapter.InboundContext, reader io.Rea } } metadata.Protocol = C.ProtocolHTTP - metadata.Domain = M.ParseSocksaddr(request.Host).AddrString() + metadata.SniffHost = M.ParseSocksaddr(request.Host).AddrString() return nil } diff --git a/common/sniff/http_test.go b/common/sniff/http_test.go index 9f64efa85e..bfdb821612 100644 --- a/common/sniff/http_test.go +++ b/common/sniff/http_test.go @@ -17,7 +17,7 @@ func TestSniffHTTP1(t *testing.T) { var metadata adapter.InboundContext err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt)) require.NoError(t, err) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func TestSniffHTTP1WithPort(t *testing.T) { @@ -26,5 +26,5 @@ func TestSniffHTTP1WithPort(t *testing.T) { var metadata adapter.InboundContext err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt)) require.NoError(t, err) - require.Equal(t, metadata.Domain, "www.gov.cn") + require.Equal(t, metadata.SniffHost, "www.gov.cn") } diff --git a/common/sniff/quic.go b/common/sniff/quic.go index 049bd2c14e..da466364c9 100644 --- a/common/sniff/quic.go +++ b/common/sniff/quic.go @@ -306,7 +306,7 @@ find: metadata.SniffContext = fragments return E.Cause1(ErrNeedMoreData, err) } - metadata.Domain = fingerprint.ServerName + metadata.SniffHost = fingerprint.ServerName for metadata.Client == "" { if len(frameTypeList) == 1 { metadata.Client = C.ClientFirefox diff --git a/common/sniff/quic_test.go b/common/sniff/quic_test.go index e2f5372472..3e70a4c71d 100644 --- a/common/sniff/quic_test.go +++ b/common/sniff/quic_test.go @@ -29,7 +29,7 @@ func TestSniffQUICChromeNew(t *testing.T) { require.NoError(t, err) err = sniff.QUICClientHello(context.Background(), &metadata, pkt) require.NoError(t, err) - require.Equal(t, "www.google.com", metadata.Domain) + require.Equal(t, "www.google.com", metadata.SniffHost) } func TestSniffQUICChromium(t *testing.T) { @@ -45,7 +45,7 @@ func TestSniffQUICChromium(t *testing.T) { require.NoError(t, err) err = sniff.QUICClientHello(context.Background(), &metadata, pkt) require.NoError(t, err) - require.Equal(t, metadata.Domain, "google.com") + require.Equal(t, metadata.SniffHost, "google.com") } func TestSniffUQUICChrome115(t *testing.T) { @@ -57,7 +57,7 @@ func TestSniffUQUICChrome115(t *testing.T) { require.NoError(t, err) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) require.Equal(t, metadata.Client, C.ClientChromium) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func TestSniffQUICFirefox(t *testing.T) { @@ -69,7 +69,7 @@ func TestSniffQUICFirefox(t *testing.T) { require.NoError(t, err) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) require.Equal(t, metadata.Client, C.ClientFirefox) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func TestSniffQUICSafari(t *testing.T) { @@ -81,7 +81,7 @@ func TestSniffQUICSafari(t *testing.T) { require.NoError(t, err) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) require.Equal(t, metadata.Client, C.ClientSafari) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func FuzzSniffQUIC(f *testing.F) { diff --git a/common/sniff/tls.go b/common/sniff/tls.go index 613086e810..4e1db94b4c 100644 --- a/common/sniff/tls.go +++ b/common/sniff/tls.go @@ -22,7 +22,7 @@ func TLSClientHello(ctx context.Context, metadata *adapter.InboundContext, reade }).HandshakeContext(ctx) if clientHello != nil { metadata.Protocol = C.ProtocolTLS - metadata.Domain = clientHello.ServerName + metadata.SniffHost = clientHello.ServerName return nil } if errors.Is(err, io.ErrUnexpectedEOF) { diff --git a/experimental/clashapi/configs.go b/experimental/clashapi/configs.go index 8ae1d258bb..33ad093fa3 100644 --- a/experimental/clashapi/configs.go +++ b/experimental/clashapi/configs.go @@ -28,6 +28,7 @@ type configSchema struct { Mode string `json:"mode"` // sing-box added ModeList []string `json:"mode-list"` + Modes []string `json:"modes"` LogLevel string `json:"log-level"` IPv6 bool `json:"ipv6"` Tun map[string]any `json:"tun"` @@ -44,6 +45,8 @@ func getConfigs(server *Server, logFactory log.Factory) func(w http.ResponseWrit render.JSON(w, r, &configSchema{ Mode: server.mode, ModeList: server.modeList, + Modes: server.modeList, + AllowLan: true, BindAddress: "*", LogLevel: log.FormatLevel(logLevel), }) diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go index 23500cd04d..c6640812a2 100644 --- a/experimental/clashapi/trafficontrol/tracker.go +++ b/experimental/clashapi/trafficontrol/tracker.go @@ -2,6 +2,7 @@ package trafficontrol import ( "net" + "net/netip" "sync/atomic" "time" @@ -36,10 +37,16 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { inbound = t.Metadata.InboundType } var domain string - if t.Metadata.Domain != "" { + if t.Metadata.Destination.Fqdn != "" { + domain = t.Metadata.Destination.Fqdn + } else { domain = t.Metadata.Domain + } + var destinationAddr netip.Addr + if len(t.Metadata.DestinationAddresses) > 0 { + destinationAddr = t.Metadata.DestinationAddresses[0] } else { - domain = t.Metadata.Destination.Fqdn + destinationAddr = t.Metadata.Destination.Addr } var processPath string if t.Metadata.ProcessInfo != nil { @@ -70,10 +77,11 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { "network": t.Metadata.Network, "type": inbound, "sourceIP": t.Metadata.Source.Addr, - "destinationIP": t.Metadata.Destination.Addr, + "destinationIP": destinationAddr, "sourcePort": F.ToString(t.Metadata.Source.Port), "destinationPort": F.ToString(t.Metadata.Destination.Port), "host": domain, + "sniffHost": t.Metadata.SniffHost, "dnsMode": "normal", "processPath": processPath, }, diff --git a/route/route.go b/route/route.go index 324b76829a..76bcd0082b 100644 --- a/route/route.go +++ b/route/route.go @@ -13,10 +13,10 @@ import ( "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-mux" - "github.com/sagernet/sing-tun" + mux "github.com/sagernet/sing-mux" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" - "github.com/sagernet/sing-vmess" + vmess "github.com/sagernet/sing-vmess" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio" @@ -279,7 +279,7 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m for _, tracker := range r.trackers { conn = tracker.RoutedPacketConnection(ctx, conn, metadata, selectedRule, selectedOutbound) } - if metadata.FakeIP { + if metadata.FakeIP || metadata.DestOverride { conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination) } if outboundHandler, isHandler := selectedOutbound.(adapter.PacketConnectionHandlerEx); isHandler { @@ -653,19 +653,20 @@ func (r *Router) actionSniff( metadata.SnifferNames = action.SnifferNames metadata.SniffError = err if err == nil { + if metadata.SniffHost != "" && metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost, ", client: ", metadata.Client) + } else if metadata.SniffHost != "" { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost) + } else { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol) + } //goland:noinspection GoDeprecation - if action.OverrideDestination && M.IsDomainName(metadata.Domain) { + if !metadata.Destination.IsFqdn() && action.OverrideDestination && M.IsDomainName(metadata.SniffHost) { metadata.Destination = M.Socksaddr{ - Fqdn: metadata.Domain, + Fqdn: metadata.SniffHost, Port: metadata.Destination.Port, } - } - if metadata.Domain != "" && metadata.Client != "" { - r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client) - } else if metadata.Domain != "" { - r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain) - } else { - r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol) + r.logger.DebugContext(ctx, "connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) } } if !sniffBuffer.IsEmpty() { @@ -785,22 +786,25 @@ func (r *Router) actionSniff( } finally: if err == nil { - //goland:noinspection GoDeprecation - if action.OverrideDestination && M.IsDomainName(metadata.Domain) { - metadata.Destination = M.Socksaddr{ - Fqdn: metadata.Domain, - Port: metadata.Destination.Port, - } - } - if metadata.Domain != "" && metadata.Client != "" { - r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client) - } else if metadata.Domain != "" { - r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain) + if metadata.SniffHost != "" && metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost, ", client: ", metadata.Client) + } else if metadata.SniffHost != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost) } else if metadata.Client != "" { r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", client: ", metadata.Client) } else { r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol) } + //goland:noinspection GoDeprecation + if action.OverrideDestination && M.IsDomainName(metadata.SniffHost) { + metadata.OriginDestination = metadata.Destination + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.SniffHost, + Port: metadata.Destination.Port, + } + metadata.DestOverride = true + r.logger.DebugContext(ctx, "packet connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) + } } } return diff --git a/route/rule/rule_item_adguard.go b/route/rule/rule_item_adguard.go index 84252e606d..cb8f932325 100644 --- a/route/rule/rule_item_adguard.go +++ b/route/rule/rule_item_adguard.go @@ -27,10 +27,12 @@ func NewRawAdGuardDomainItem(matcher *domain.AdGuardMatcher) *AdGuardDomainItem func (r *AdGuardDomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain.go b/route/rule/rule_item_domain.go index af790aa385..40281ef4f6 100644 --- a/route/rule/rule_item_domain.go +++ b/route/rule/rule_item_domain.go @@ -63,10 +63,12 @@ func NewRawDomainItem(matcher *domain.Matcher) *DomainItem { func (r *DomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain_keyword.go b/route/rule/rule_item_domain_keyword.go index 6e19a10ccd..46afd0ee81 100644 --- a/route/rule/rule_item_domain_keyword.go +++ b/route/rule/rule_item_domain_keyword.go @@ -18,10 +18,12 @@ func NewDomainKeywordItem(keywords []string) *DomainKeywordItem { func (r *DomainKeywordItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain_regex.go b/route/rule/rule_item_domain_regex.go index b9752a45ad..5876e76f3d 100644 --- a/route/rule/rule_item_domain_regex.go +++ b/route/rule/rule_item_domain_regex.go @@ -39,10 +39,12 @@ func NewDomainRegexItem(expressions []string) (*DomainRegexItem, error) { func (r *DomainRegexItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain } if domainHost == "" { return false diff --git a/route/rule/rule_item_preferred_by.go b/route/rule/rule_item_preferred_by.go index 42c8a62786..69173a4516 100644 --- a/route/rule/rule_item_preferred_by.go +++ b/route/rule/rule_item_preferred_by.go @@ -43,10 +43,12 @@ func (r *PreferredByItem) Start() error { func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain } if domainHost != "" { for _, outbound := range r.outbounds { From 372715fd364e872113dd3b758d5db4c0e6569174 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 7 May 2025 00:26:57 +0800 Subject: [PATCH 13/57] Add `domain_match_strategy` and `default_domain_match_strategy` --- adapter/router.go | 1 + constant/rule.go | 10 +++++ option/route.go | 1 + option/rule.go | 8 ++-- option/rule_set.go | 8 ++-- option/types.go | 62 ++++++++++++++++++++++++++ route/router.go | 11 +++++ route/rule/rule_abstract.go | 10 +++-- route/rule/rule_default.go | 27 +++++++---- route/rule/rule_dns.go | 6 +-- route/rule/rule_headless.go | 28 ++++++++---- route/rule/rule_item_adguard.go | 41 +++++++++++++---- route/rule/rule_item_domain.go | 43 +++++++++++++----- route/rule/rule_item_domain_keyword.go | 39 ++++++++++++---- route/rule/rule_item_domain_regex.go | 41 ++++++++++++----- route/rule/rule_item_preferred_by.go | 46 ++++++++++++++----- 16 files changed, 301 insertions(+), 81 deletions(-) diff --git a/adapter/router.go b/adapter/router.go index 82e6881a60..5b917b7efe 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -30,6 +30,7 @@ type Router interface { NeighborResolver() NeighborResolver AppendTracker(tracker ConnectionTracker) ResetNetwork() + DefaultDomainMatchStrategy() C.DomainMatchStrategy } type ConnectionTracker interface { diff --git a/constant/rule.go b/constant/rule.go index 55cad2e137..d655fdbc3e 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -43,3 +43,13 @@ const ( RuleActionRejectMethodDrop = "drop" RuleActionRejectMethodReply = "reply" ) + +type DomainMatchStrategy = uint8 + +const ( + DomainMatchStrategyAsIS DomainMatchStrategy = iota + DomainMatchStrategyPreferFQDN + DomainMatchStrategyPreferSniffHost + DomainMatchStrategyFQDNOnly + DomainMatchStrategySniffHostOnly +) diff --git a/option/route.go b/option/route.go index 0c3e576d13..2542b8a930 100644 --- a/option/route.go +++ b/option/route.go @@ -20,6 +20,7 @@ type RouteOptions struct { DefaultNetworkType badoption.Listable[InterfaceType] `json:"default_network_type,omitempty"` DefaultFallbackNetworkType badoption.Listable[InterfaceType] `json:"default_fallback_network_type,omitempty"` DefaultFallbackDelay badoption.Duration `json:"default_fallback_delay,omitempty"` + DefaultDomainMatchStrategy DomainMatchStrategy `json:"default_domain_match_strategy,omitempty"` } type GeoIPOptions struct { diff --git a/option/rule.go b/option/rule.go index b792ccf4b2..6b24131ab3 100644 --- a/option/rule.go +++ b/option/rule.go @@ -108,6 +108,7 @@ type RawDefaultRule struct { PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` Invert bool `json:"invert,omitempty"` // Deprecated: renamed to rule_set_ip_cidr_match_source @@ -138,9 +139,10 @@ func (r DefaultRule) IsValid() bool { } type RawLogicalRule struct { - Mode string `json:"mode"` - Rules []Rule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` + Mode string `json:"mode"` + Rules []Rule `json:"rules,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` + Invert bool `json:"invert,omitempty"` } type LogicalRule struct { diff --git a/option/rule_set.go b/option/rule_set.go index b06342280b..5db3fe14a3 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -203,6 +203,7 @@ type DefaultHeadlessRule struct { NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` @@ -223,9 +224,10 @@ func (r DefaultHeadlessRule) IsValid() bool { } type LogicalHeadlessRule struct { - Mode string `json:"mode"` - Rules []HeadlessRule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` + Mode string `json:"mode"` + Rules []HeadlessRule `json:"rules,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` + Invert bool `json:"invert,omitempty"` } func (r LogicalHeadlessRule) IsValid() bool { diff --git a/option/types.go b/option/types.go index fe7d4b3d07..66c607321e 100644 --- a/option/types.go +++ b/option/types.go @@ -194,3 +194,65 @@ func (t *InterfaceType) UnmarshalJSON(content []byte) error { *t = InterfaceType(interfaceType) return nil } + +type DomainMatchStrategy C.DomainMatchStrategy + +func (s DomainMatchStrategy) String() string { + switch C.DomainMatchStrategy(s) { + case C.DomainMatchStrategyAsIS: + return "" + case C.DomainMatchStrategyPreferFQDN: + return "prefer_fqdn" + case C.DomainMatchStrategyPreferSniffHost: + return "prefer_sniffhost" + case C.DomainMatchStrategyFQDNOnly: + return "fqdn_only" + case C.DomainMatchStrategySniffHostOnly: + return "sniffhost_only" + default: + panic(E.New("unknown domain match strategy: ", s)) + } +} + +func (s DomainMatchStrategy) MarshalJSON() ([]byte, error) { + var value string + switch C.DomainMatchStrategy(s) { + case C.DomainMatchStrategyAsIS: + value = "" + // value = "as_is" + case C.DomainMatchStrategyPreferFQDN: + value = "prefer_fqdn" + case C.DomainMatchStrategyPreferSniffHost: + value = "prefer_sniffhost" + case C.DomainMatchStrategyFQDNOnly: + value = "fqdn_only" + case C.DomainMatchStrategySniffHostOnly: + value = "sniffhost_only" + default: + return nil, E.New("unknown domain match strategy: ", s) + } + return json.Marshal(value) +} + +func (s *DomainMatchStrategy) UnmarshalJSON(bytes []byte) error { + var value string + err := json.Unmarshal(bytes, &value) + if err != nil { + return err + } + switch value { + case "", "as_is": + *s = DomainMatchStrategy(C.DomainMatchStrategyAsIS) + case "prefer_fqdn": + *s = DomainMatchStrategy(C.DomainMatchStrategyPreferFQDN) + case "prefer_sniffhost": + *s = DomainMatchStrategy(C.DomainMatchStrategyPreferSniffHost) + case "fqdn_only": + *s = DomainMatchStrategy(C.DomainMatchStrategyFQDNOnly) + case "sniffhost_only": + *s = DomainMatchStrategy(C.DomainMatchStrategySniffHostOnly) + default: + return E.New("unknown domain match strategy: ", value) + } + return nil +} diff --git a/route/router.go b/route/router.go index c141581d01..3be873913a 100644 --- a/route/router.go +++ b/route/router.go @@ -41,6 +41,8 @@ type Router struct { trackers []adapter.ConnectionTracker platformInterface adapter.PlatformInterface started bool + + defaultDomainMatchStrategy C.DomainMatchStrategy } func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions) *Router { @@ -60,10 +62,15 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route leaseFiles: options.DHCPLeaseFiles, pauseManager: service.FromContext[pause.Manager](ctx), platformInterface: service.FromContext[adapter.PlatformInterface](ctx), + + defaultDomainMatchStrategy: C.DomainMatchStrategy(options.DefaultDomainMatchStrategy), } } func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) error { + if r.defaultDomainMatchStrategy == C.DomainMatchStrategyFQDNOnly || r.defaultDomainMatchStrategy == C.DomainMatchStrategySniffHostOnly { + return E.New("default_domain_match_strategy cannot be fqdn_only or sniffhost_only") + } for i, options := range rules { rule, err := R.NewRule(r.ctx, r.logger, options, false) if err != nil { @@ -261,3 +268,7 @@ func (r *Router) ResetNetwork() { r.network.ResetNetwork() r.dns.ResetNetwork() } + +func (r *Router) DefaultDomainMatchStrategy() C.DomainMatchStrategy { + return r.defaultDomainMatchStrategy +} diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 45d5b8931f..ac85f5f1bf 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -19,6 +19,7 @@ type abstractDefaultRule struct { destinationPortItems []RuleItem allItems []RuleItem ruleSetItem RuleItem + domainMatchStrategy C.DomainMatchStrategy invert bool action adapter.RuleAction } @@ -149,10 +150,11 @@ func (r *abstractDefaultRule) String() string { } type abstractLogicalRule struct { - rules []adapter.HeadlessRule - mode string - invert bool - action adapter.RuleAction + rules []adapter.HeadlessRule + mode string + domainMatchStrategy C.DomainMatchStrategy + invert bool + action adapter.RuleAction } func (r *abstractLogicalRule) Type() string { diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 7ffdd521cb..28d3a156b7 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -59,12 +59,16 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio } rule := &DefaultRule{ abstractDefaultRule{ - invert: options.Invert, - action: action, + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, + action: action, }, } router := service.FromContext[adapter.Router](ctx) networkManager := service.FromContext[adapter.NetworkManager](ctx) + if rule.domainMatchStrategy == C.DomainMatchStrategyAsIS { + rule.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) rule.items = append(rule.items, item) @@ -101,7 +105,7 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.allItems = append(rule.allItems, item) } if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { - item, err := NewDomainItem(options.Domain, options.DomainSuffix) + item, err := NewDomainItem(options.Domain, options.DomainSuffix, rule.domainMatchStrategy) if err != nil { return nil, err } @@ -109,12 +113,12 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.allItems = append(rule.allItems, item) } if len(options.DomainKeyword) > 0 { - item := NewDomainKeywordItem(options.DomainKeyword) + item := NewDomainKeywordItem(options.DomainKeyword, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainRegex) > 0 { - item, err := NewDomainRegexItem(options.DomainRegex) + item, err := NewDomainRegexItem(options.DomainRegex, rule.domainMatchStrategy) if err != nil { return nil, err } @@ -271,7 +275,7 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.allItems = append(rule.allItems, item) } if len(options.PreferredBy) > 0 { - item := NewPreferredByItem(ctx, options.PreferredBy) + item := NewPreferredByItem(ctx, options.PreferredBy, rule.domainMatchStrategy) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } @@ -304,11 +308,16 @@ func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options optio } rule := &LogicalRule{ abstractLogicalRule{ - rules: make([]adapter.HeadlessRule, len(options.Rules)), - invert: options.Invert, - action: action, + rules: make([]adapter.HeadlessRule, len(options.Rules)), + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, + action: action, }, } + router := service.FromContext[adapter.Router](ctx) + if rule.domainMatchStrategy == C.DomainMatchStrategyAsIS { + rule.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } switch options.Mode { case C.LogicalTypeAnd: rule.mode = C.LogicalTypeAnd diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 957df8747d..9c93a6651f 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -92,7 +92,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.allItems = append(rule.allItems, item) } if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { - item, err := NewDomainItem(options.Domain, options.DomainSuffix) + item, err := NewDomainItem(options.Domain, options.DomainSuffix, C.DomainMatchStrategyAsIS) if err != nil { return nil, err } @@ -100,12 +100,12 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.allItems = append(rule.allItems, item) } if len(options.DomainKeyword) > 0 { - item := NewDomainKeywordItem(options.DomainKeyword) + item := NewDomainKeywordItem(options.DomainKeyword, C.DomainMatchStrategyAsIS) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainRegex) > 0 { - item, err := NewDomainRegexItem(options.DomainRegex) + item, err := NewDomainRegexItem(options.DomainRegex, C.DomainMatchStrategyAsIS) if err != nil { return nil, E.Cause(err, "domain_regex") } diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index 689e6e3ecc..84a3ab6644 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -38,33 +38,38 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR networkManager := service.FromContext[adapter.NetworkManager](ctx) rule := &DefaultHeadlessRule{ abstractDefaultRule{ - invert: options.Invert, + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, }, } + router := service.FromContext[adapter.Router](ctx) + if rule.domainMatchStrategy == C.DomainMatchStrategyAsIS { + rule.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } if len(options.Network) > 0 { item := NewNetworkItem(options.Network) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { - item, err := NewDomainItem(options.Domain, options.DomainSuffix) + item, err := NewDomainItem(options.Domain, options.DomainSuffix, rule.domainMatchStrategy) if err != nil { return nil, err } rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } else if options.DomainMatcher != nil { - item := NewRawDomainItem(options.DomainMatcher) + item := NewRawDomainItem(options.DomainMatcher, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainKeyword) > 0 { - item := NewDomainKeywordItem(options.DomainKeyword) + item := NewDomainKeywordItem(options.DomainKeyword, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainRegex) > 0 { - item, err := NewDomainRegexItem(options.DomainRegex) + item, err := NewDomainRegexItem(options.DomainRegex, rule.domainMatchStrategy) if err != nil { return nil, E.Cause(err, "domain_regex") } @@ -182,11 +187,11 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR } } if len(options.AdGuardDomain) > 0 { - item := NewAdGuardDomainItem(options.AdGuardDomain) + item := NewAdGuardDomainItem(options.AdGuardDomain, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } else if options.AdGuardDomainMatcher != nil { - item := NewRawAdGuardDomainItem(options.AdGuardDomainMatcher) + item := NewRawAdGuardDomainItem(options.AdGuardDomainMatcher, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } @@ -202,10 +207,15 @@ type LogicalHeadlessRule struct { func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) { r := &LogicalHeadlessRule{ abstractLogicalRule{ - rules: make([]adapter.HeadlessRule, len(options.Rules)), - invert: options.Invert, + rules: make([]adapter.HeadlessRule, len(options.Rules)), + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, }, } + router := service.FromContext[adapter.Router](ctx) + if r.domainMatchStrategy == C.DomainMatchStrategyAsIS { + r.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } switch options.Mode { case C.LogicalTypeAnd: r.mode = C.LogicalTypeAnd diff --git a/route/rule/rule_item_adguard.go b/route/rule/rule_item_adguard.go index cb8f932325..f74eb58561 100644 --- a/route/rule/rule_item_adguard.go +++ b/route/rule/rule_item_adguard.go @@ -4,35 +4,58 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/domain" ) var _ RuleItem = (*AdGuardDomainItem)(nil) type AdGuardDomainItem struct { - matcher *domain.AdGuardMatcher + matcher *domain.AdGuardMatcher + domainMatchStrategy C.DomainMatchStrategy } -func NewAdGuardDomainItem(ruleLines []string) *AdGuardDomainItem { +func NewAdGuardDomainItem(ruleLines []string, domainMatchStrategy C.DomainMatchStrategy) *AdGuardDomainItem { return &AdGuardDomainItem{ domain.NewAdGuardMatcher(ruleLines), + domainMatchStrategy, } } -func NewRawAdGuardDomainItem(matcher *domain.AdGuardMatcher) *AdGuardDomainItem { +func NewRawAdGuardDomainItem(matcher *domain.AdGuardMatcher, domainMatchStrategy C.DomainMatchStrategy) *AdGuardDomainItem { return &AdGuardDomainItem{ matcher, + domainMatchStrategy, } } func (r *AdGuardDomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.SniffHost != "" { - domainHost = metadata.SniffHost - } else if metadata.Destination.IsFqdn() { - domainHost = metadata.Destination.Fqdn - } else { - domainHost = metadata.Domain + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain.go b/route/rule/rule_item_domain.go index 40281ef4f6..762d9e4b30 100644 --- a/route/rule/rule_item_domain.go +++ b/route/rule/rule_item_domain.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/domain" E "github.com/sagernet/sing/common/exceptions" ) @@ -11,11 +12,12 @@ import ( var _ RuleItem = (*DomainItem)(nil) type DomainItem struct { - matcher *domain.Matcher - description string + matcher *domain.Matcher + description string + domainMatchStrategy C.DomainMatchStrategy } -func NewDomainItem(domains []string, domainSuffixes []string) (*DomainItem, error) { +func NewDomainItem(domains []string, domainSuffixes []string, domainMatchStrategy C.DomainMatchStrategy) (*DomainItem, error) { for _, domainItem := range domains { if domainItem == "" { return nil, E.New("domain: empty item is not allowed") @@ -51,24 +53,45 @@ func NewDomainItem(domains []string, domainSuffixes []string) (*DomainItem, erro return &DomainItem{ domain.NewMatcher(domains, domainSuffixes, false), description, + domainMatchStrategy, }, nil } -func NewRawDomainItem(matcher *domain.Matcher) *DomainItem { +func NewRawDomainItem(matcher *domain.Matcher, domainMatchStrategy C.DomainMatchStrategy) *DomainItem { return &DomainItem{ matcher, "domain/domain_suffix=", + domainMatchStrategy, } } func (r *DomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.SniffHost != "" { - domainHost = metadata.SniffHost - } else if metadata.Destination.IsFqdn() { - domainHost = metadata.Destination.Fqdn - } else { - domainHost = metadata.Domain + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain_keyword.go b/route/rule/rule_item_domain_keyword.go index 46afd0ee81..5ca429ea1b 100644 --- a/route/rule/rule_item_domain_keyword.go +++ b/route/rule/rule_item_domain_keyword.go @@ -4,26 +4,47 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" ) var _ RuleItem = (*DomainKeywordItem)(nil) type DomainKeywordItem struct { - keywords []string + keywords []string + domainMatchStrategy C.DomainMatchStrategy } -func NewDomainKeywordItem(keywords []string) *DomainKeywordItem { - return &DomainKeywordItem{keywords} +func NewDomainKeywordItem(keywords []string, domainMatchStrategy C.DomainMatchStrategy) *DomainKeywordItem { + return &DomainKeywordItem{keywords, domainMatchStrategy} } func (r *DomainKeywordItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.SniffHost != "" { - domainHost = metadata.SniffHost - } else if metadata.Destination.IsFqdn() { - domainHost = metadata.Destination.Fqdn - } else { - domainHost = metadata.Domain + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain_regex.go b/route/rule/rule_item_domain_regex.go index 5876e76f3d..6c842986ae 100644 --- a/route/rule/rule_item_domain_regex.go +++ b/route/rule/rule_item_domain_regex.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" ) @@ -12,11 +13,12 @@ import ( var _ RuleItem = (*DomainRegexItem)(nil) type DomainRegexItem struct { - matchers []*regexp.Regexp - description string + matchers []*regexp.Regexp + description string + domainMatchStrategy C.DomainMatchStrategy } -func NewDomainRegexItem(expressions []string) (*DomainRegexItem, error) { +func NewDomainRegexItem(expressions []string, domainMatchStrategy C.DomainMatchStrategy) (*DomainRegexItem, error) { matchers := make([]*regexp.Regexp, 0, len(expressions)) for i, regex := range expressions { matcher, err := regexp.Compile(regex) @@ -34,17 +36,36 @@ func NewDomainRegexItem(expressions []string) (*DomainRegexItem, error) { } else { description += F.ToString("[", strings.Join(expressions, " "), "]") } - return &DomainRegexItem{matchers, description}, nil + return &DomainRegexItem{matchers, description, domainMatchStrategy}, nil } func (r *DomainRegexItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.SniffHost != "" { - domainHost = metadata.SniffHost - } else if metadata.Destination.IsFqdn() { - domainHost = metadata.Destination.Fqdn - } else { - domainHost = metadata.Domain + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_preferred_by.go b/route/rule/rule_item_preferred_by.go index 69173a4516..17ff348054 100644 --- a/route/rule/rule_item_preferred_by.go +++ b/route/rule/rule_item_preferred_by.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/service" @@ -13,15 +14,17 @@ import ( var _ RuleItem = (*PreferredByItem)(nil) type PreferredByItem struct { - ctx context.Context - outboundTags []string - outbounds []adapter.OutboundWithPreferredRoutes + ctx context.Context + outboundTags []string + outbounds []adapter.OutboundWithPreferredRoutes + domainMatchStrategy C.DomainMatchStrategy } -func NewPreferredByItem(ctx context.Context, outboundTags []string) *PreferredByItem { +func NewPreferredByItem(ctx context.Context, outboundTags []string, domainMatchStrategy C.DomainMatchStrategy) *PreferredByItem { return &PreferredByItem{ - ctx: ctx, - outboundTags: outboundTags, + ctx: ctx, + outboundTags: outboundTags, + domainMatchStrategy: domainMatchStrategy, } } @@ -43,12 +46,31 @@ func (r *PreferredByItem) Start() error { func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.SniffHost != "" { - domainHost = metadata.SniffHost - } else if metadata.Destination.IsFqdn() { - domainHost = metadata.Destination.Fqdn - } else { - domainHost = metadata.Domain + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost != "" { for _, outbound := range r.outbounds { From 62930f861b3e45b11d75f8a70497a04917189a8c Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 21 May 2025 02:03:09 +0800 Subject: [PATCH 14/57] Add `rcode` option for DNS reject action --- dns/router.go | 16 +++++++++++-- option/dns.go | 9 ++++---- option/dns_record.go | 48 +++++++++++++++++++++++++++++++++++++++ option/rule_action.go | 40 +++++++++++++++++++++++++++++--- route/rule/rule_action.go | 6 +++-- 5 files changed, 108 insertions(+), 11 deletions(-) diff --git a/dns/router.go b/dns/router.go index 98913fad61..031322fb10 100644 --- a/dns/router.go +++ b/dns/router.go @@ -13,7 +13,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -38,6 +38,7 @@ type Router struct { defaultDomainStrategy C.DomainStrategy dnsReverseMapping freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface + defaultRejectRcode int } func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) *Router { @@ -48,6 +49,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp outbound: service.FromContext[adapter.OutboundManager](ctx), rules: make([]adapter.DNSRule, 0, len(options.Rules)), defaultDomainStrategy: C.DomainStrategy(options.Strategy), + defaultRejectRcode: options.DefaultRejectRcode.Build(), } router.client = NewClient(ClientOptions{ DisableCache: options.DNSClientOptions.DisableCache, @@ -266,10 +268,20 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: + var rcode int + if action.Rcode == -1 { + if r.defaultRejectRcode == -1 { + rcode = mDNS.RcodeRefused + } else { + rcode = r.defaultRejectRcode + } + } else { + rcode = action.Rcode + } return &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ Id: message.Id, - Rcode: mDNS.RcodeRefused, + Rcode: rcode, Response: true, }, Question: []mDNS.Question{message.Question[0]}, diff --git a/option/dns.go b/option/dns.go index 4c1ac208bf..d4688d0f0d 100644 --- a/option/dns.go +++ b/option/dns.go @@ -19,10 +19,11 @@ import ( ) type RawDNSOptions struct { - Servers []DNSServerOptions `json:"servers,omitempty"` - Rules []DNSRule `json:"rules,omitempty"` - Final string `json:"final,omitempty"` - ReverseMapping bool `json:"reverse_mapping,omitempty"` + Servers []DNSServerOptions `json:"servers,omitempty"` + Rules []DNSRule `json:"rules,omitempty"` + Final string `json:"final,omitempty"` + ReverseMapping bool `json:"reverse_mapping,omitempty"` + DefaultRejectRcode *DNSRejectRCode `json:"default_reject_rcode,omitempty"` DNSClientOptions } diff --git a/option/dns_record.go b/option/dns_record.go index fa72b61b73..e5ca1fe8da 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -48,6 +48,54 @@ func (r *DNSRCode) Build() int { return int(*r) } +type DNSRejectRCode int + +func (r DNSRejectRCode) MarshalJSON() ([]byte, error) { + if int(r) == -1 { + return json.Marshal(string("")) + } + rCodeValue, loaded := dns.RcodeToString[int(r)] + if loaded { + return json.Marshal(rCodeValue) + } + return json.Marshal(int(r)) +} + +func (r *DNSRejectRCode) UnmarshalJSON(bytes []byte) error { + var intValue int + err := json.Unmarshal(bytes, &intValue) + if err == nil { + if intValue == -1 { + *r = -1 + return nil + } + *r = DNSRejectRCode(intValue) + return nil + } + var stringValue string + err = json.Unmarshal(bytes, &stringValue) + if err != nil { + return err + } + if stringValue == "" { + *r = -1 + return nil + } + rCodeValue, loaded := dns.StringToRcode[stringValue] + if !loaded { + return E.New("unknown rcode: " + stringValue) + } + *r = DNSRejectRCode(rCodeValue) + return nil +} + +func (r *DNSRejectRCode) Build() int { + if r == nil { + return -1 + } + return int(*r) +} + type DNSRecordOptions struct { dns.RR fromBase64 bool diff --git a/option/rule_action.go b/option/rule_action.go index 4310825520..243ed6b61c 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -100,7 +100,7 @@ type _DNSRuleAction struct { Action string `json:"action,omitempty"` RouteOptions DNSRouteActionOptions `json:"-"` RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"` - RejectOptions RejectActionOptions `json:"-"` + DNSRejectOptions DNSRejectActionOptions `json:"-"` PredefinedOptions DNSRouteActionPredefined `json:"-"` } @@ -118,7 +118,7 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { case C.RuleActionTypeRouteOptions: v = r.RouteOptionsOptions case C.RuleActionTypeReject: - v = r.RejectOptions + v = r.DNSRejectOptions case C.RuleActionTypePredefined: v = r.PredefinedOptions default: @@ -140,7 +140,7 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e case C.RuleActionTypeRouteOptions: v = &r.RouteOptionsOptions case C.RuleActionTypeReject: - v = &r.RejectOptions + v = &r.DNSRejectOptions case C.RuleActionTypePredefined: v = &r.PredefinedOptions default: @@ -320,3 +320,37 @@ type DNSRouteActionPredefined struct { Ns badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"` Extra badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"` } + +type _DNSRejectActionOptions struct { + Rcode *DNSRejectRCode `json:"rcode,omitempty"` + Method string `json:"method,omitempty"` + NoDrop bool `json:"no_drop,omitempty"` +} + +type DNSRejectActionOptions _DNSRejectActionOptions + +func (r DNSRejectActionOptions) MarshalJSON() ([]byte, error) { + switch r.Method { + case C.RuleActionRejectMethodDefault: + r.Method = "" + } + return json.Marshal((_DNSRejectActionOptions)(r)) +} + +func (r *DNSRejectActionOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_DNSRejectActionOptions)(r)) + if err != nil { + return err + } + switch r.Method { + case "", C.RuleActionRejectMethodDefault: + r.Method = C.RuleActionRejectMethodDefault + case C.RuleActionRejectMethodDrop: + default: + return E.New("unknown reject method: " + r.Method) + } + if r.Method == C.RuleActionRejectMethodDrop && r.NoDrop { + return E.New("no_drop is not available in current context") + } + return nil +} diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index cac814e765..4da581ec48 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -141,8 +141,9 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) } case C.RuleActionTypeReject: return &RuleActionReject{ - Method: action.RejectOptions.Method, - NoDrop: action.RejectOptions.NoDrop, + Rcode: action.DNSRejectOptions.Rcode.Build(), + Method: action.DNSRejectOptions.Method, + NoDrop: action.DNSRejectOptions.NoDrop, logger: logger, } case C.RuleActionTypePredefined: @@ -353,6 +354,7 @@ func IsBypassed(err error) bool { } type RuleActionReject struct { + Rcode int Method string NoDrop bool logger logger.ContextLogger From 2e19898120e5a38bd85439ea6ff082383631dbd0 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Mon, 17 Mar 2025 16:21:40 +0800 Subject: [PATCH 15/57] Add `sniff-override-destination` to rule action --- constant/rule.go | 19 +++++++++-------- option/rule_action.go | 4 ++++ route/route.go | 44 +++++++++++++++++++++++---------------- route/rule/rule_action.go | 16 +++++++++++--- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/constant/rule.go b/constant/rule.go index d655fdbc3e..bbe201ab5a 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -27,15 +27,16 @@ const ( ) const ( - RuleActionTypeRoute = "route" - RuleActionTypeRouteOptions = "route-options" - RuleActionTypeDirect = "direct" - RuleActionTypeBypass = "bypass" - RuleActionTypeReject = "reject" - RuleActionTypeHijackDNS = "hijack-dns" - RuleActionTypeSniff = "sniff" - RuleActionTypeResolve = "resolve" - RuleActionTypePredefined = "predefined" + RuleActionTypeRoute = "route" + RuleActionTypeRouteOptions = "route-options" + RuleActionTypeDirect = "direct" + RuleActionTypeBypass = "bypass" + RuleActionTypeReject = "reject" + RuleActionTypeHijackDNS = "hijack-dns" + RuleActionTypeSniff = "sniff" + RuleActionTypeSniffOverrideDestination = "sniff-override-destination" + RuleActionTypeResolve = "resolve" + RuleActionTypePredefined = "predefined" ) const ( diff --git a/option/rule_action.go b/option/rule_action.go index 243ed6b61c..b141d1a572 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -47,6 +47,8 @@ func (r RuleAction) MarshalJSON() ([]byte, error) { v = nil case C.RuleActionTypeSniff: v = r.SniffOptions + case C.RuleActionTypeSniffOverrideDestination: + v = nil case C.RuleActionTypeResolve: v = r.ResolveOptions default: @@ -80,6 +82,8 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error { v = nil case C.RuleActionTypeSniff: v = &r.SniffOptions + case C.RuleActionTypeSniffOverrideDestination: + v = nil case C.RuleActionTypeResolve: v = &r.ResolveOptions default: diff --git a/route/route.go b/route/route.go index 76bcd0082b..93b01383a2 100644 --- a/route/route.go +++ b/route/route.go @@ -582,6 +582,10 @@ match: selectedRuleIndex = currentRuleIndex break match } + case *R.RuleActionSniffOverrideDestination: + if metadata.SniffHost != "" { + r.actionSniffOverrideDestination(ctx, metadata, inputConn, inputPacketConn) + } case *R.RuleActionResolve: fatalErr = r.actionResolve(ctx, metadata, action) if fatalErr != nil { @@ -660,14 +664,6 @@ func (r *Router) actionSniff( } else { r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol) } - //goland:noinspection GoDeprecation - if !metadata.Destination.IsFqdn() && action.OverrideDestination && M.IsDomainName(metadata.SniffHost) { - metadata.Destination = M.Socksaddr{ - Fqdn: metadata.SniffHost, - Port: metadata.Destination.Port, - } - r.logger.DebugContext(ctx, "connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) - } } if !sniffBuffer.IsEmpty() { buffer = sniffBuffer @@ -795,21 +791,33 @@ func (r *Router) actionSniff( } else { r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol) } - //goland:noinspection GoDeprecation - if action.OverrideDestination && M.IsDomainName(metadata.SniffHost) { - metadata.OriginDestination = metadata.Destination - metadata.Destination = M.Socksaddr{ - Fqdn: metadata.SniffHost, - Port: metadata.Destination.Port, - } - metadata.DestOverride = true - r.logger.DebugContext(ctx, "packet connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) - } } } return } +func (r *Router) actionSniffOverrideDestination(ctx context.Context, metadata *adapter.InboundContext, inputConn net.Conn, inputPacketConn N.PacketConn) { + if inputConn != nil { + if !metadata.Destination.IsFqdn() && M.IsDomainName(metadata.SniffHost) { + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.SniffHost, + Port: metadata.Destination.Port, + } + r.logger.DebugContext(ctx, "connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) + } + } else if inputPacketConn != nil { + if !metadata.Destination.IsFqdn() && M.IsDomainName(metadata.SniffHost) { + metadata.OriginDestination = metadata.Destination + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.SniffHost, + Port: metadata.Destination.Port, + } + metadata.DestOverride = true + r.logger.DebugContext(ctx, "packet connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) + } + } +} + func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionResolve) error { if metadata.Destination.IsFqdn() { var transport adapter.DNSTransport diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 4da581ec48..5eb20abff6 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -13,7 +13,7 @@ import ( "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -105,6 +105,8 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti Timeout: time.Duration(action.SniffOptions.Timeout), } return sniffAction, sniffAction.build() + case C.RuleActionTypeSniffOverrideDestination: + return &RuleActionSniffOverrideDestination{}, nil case C.RuleActionTypeResolve: return &RuleActionResolve{ Server: action.ResolveOptions.Server, @@ -419,8 +421,6 @@ type RuleActionSniff struct { StreamSniffers []sniff.StreamSniffer PacketSniffers []sniff.PacketSniffer Timeout time.Duration - // Deprecated - OverrideDestination bool } func (r *RuleActionSniff) Type() string { @@ -472,6 +472,16 @@ func (r *RuleActionSniff) String() string { } } +type RuleActionSniffOverrideDestination struct{} + +func (r *RuleActionSniffOverrideDestination) Type() string { + return C.RuleActionTypeSniffOverrideDestination +} + +func (r *RuleActionSniffOverrideDestination) String() string { + return "sniff-override-destination" +} + type RuleActionResolve struct { Server string Strategy C.DomainStrategy From b0da8a7aebdf080ef0019b0345f4d35f9ae5982f Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 19 Mar 2025 15:21:36 +0800 Subject: [PATCH 16/57] Add `match_only` option for resolve action --- adapter/inbound.go | 3 ++- option/rule_action.go | 1 + route/route.go | 34 +++++++++++++++++++++++++-- route/rule/rule_action.go | 5 ++++ route/rule/rule_item_cidr.go | 9 ++++++- route/rule/rule_item_ip_is_private.go | 7 ++++++ route/rule/rule_item_preferred_by.go | 11 ++++++++- 7 files changed, 65 insertions(+), 5 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index e053540500..f1c8c1194e 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -62,7 +62,8 @@ type InboundContext struct { // cache - Domain string + CacheIPs []netip.Addr + Domain string // Deprecated: implement in rule action InboundDetour string diff --git a/option/rule_action.go b/option/rule_action.go index b141d1a572..5265a3b979 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -316,6 +316,7 @@ type RouteActionResolve struct { DisableCache bool `json:"disable_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + MatchOnly bool `json:"match_only,omitempty"` } type DNSRouteActionPredefined struct { diff --git a/route/route.go b/route/route.go index 93b01383a2..9fc2c481b4 100644 --- a/route/route.go +++ b/route/route.go @@ -838,8 +838,38 @@ func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundCon if err != nil { return err } - metadata.DestinationAddresses = addresses - r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") + if action.MatchOnly { + metadata.CacheIPs = addresses + r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.CacheIPs), " "), "] for match only") + } else { + metadata.DestinationAddresses = addresses + r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") + } + if len(addresses) > 0 { + if isAllIPv4(addresses) { + metadata.IPVersion = 4 + } else if isAllIPv6(addresses) { + metadata.IPVersion = 6 + } + } } return nil } + +func isAllIPv4(addresses []netip.Addr) bool { + for _, addr := range addresses { + if !addr.Is4() { + return false + } + } + return true +} + +func isAllIPv6(addresses []netip.Addr) bool { + for _, addr := range addresses { + if !addr.Is6() { + return false + } + } + return true +} diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 5eb20abff6..8b7df0352a 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -114,6 +114,7 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti DisableCache: action.ResolveOptions.DisableCache, RewriteTTL: action.ResolveOptions.RewriteTTL, ClientSubnet: action.ResolveOptions.ClientSubnet.Build(netip.Prefix{}), + MatchOnly: action.ResolveOptions.MatchOnly, }, nil default: panic(F.ToString("unknown rule action: ", action.Action)) @@ -488,6 +489,7 @@ type RuleActionResolve struct { DisableCache bool RewriteTTL *uint32 ClientSubnet netip.Prefix + MatchOnly bool } func (r *RuleActionResolve) Type() string { @@ -511,6 +513,9 @@ func (r *RuleActionResolve) String() string { if r.ClientSubnet.IsValid() { options = append(options, F.ToString("client_subnet=", r.ClientSubnet)) } + if r.MatchOnly { + options = append(options, "match_only") + } if len(options) == 0 { return "resolve" } else { diff --git a/route/rule/rule_item_cidr.go b/route/rule/rule_item_cidr.go index c823dcf30a..f4d70775f8 100644 --- a/route/rule/rule_item_cidr.go +++ b/route/rule/rule_item_cidr.go @@ -79,12 +79,19 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { if metadata.Destination.IsIP() { return r.ipSet.Contains(metadata.Destination.Addr) } - if len(metadata.DestinationAddresses) > 0 { + if len(metadata.DestinationAddresses) > 0 || len(metadata.CacheIPs) > 0 { for _, address := range metadata.DestinationAddresses { if r.ipSet.Contains(address) { return true } } + if len(metadata.CacheIPs) > 0 { + for _, address := range metadata.CacheIPs { + if r.ipSet.Contains(address) { + return true + } + } + } return false } return metadata.IPCIDRAcceptEmpty diff --git a/route/rule/rule_item_ip_is_private.go b/route/rule/rule_item_ip_is_private.go index e185db1db4..17b30602ff 100644 --- a/route/rule/rule_item_ip_is_private.go +++ b/route/rule/rule_item_ip_is_private.go @@ -33,6 +33,13 @@ func (r *IPIsPrivateItem) Match(metadata *adapter.InboundContext) bool { return true } } + if len(metadata.CacheIPs) > 0 { + for _, destinationAddress := range metadata.CacheIPs { + if !N.IsPublicAddr(destinationAddress) { + return true + } + } + } } return false } diff --git a/route/rule/rule_item_preferred_by.go b/route/rule/rule_item_preferred_by.go index 17ff348054..18633cd726 100644 --- a/route/rule/rule_item_preferred_by.go +++ b/route/rule/rule_item_preferred_by.go @@ -86,7 +86,7 @@ func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { } } } - if len(metadata.DestinationAddresses) > 0 { + if len(metadata.DestinationAddresses) > 0 || len(metadata.CacheIPs) > 0 { for _, address := range metadata.DestinationAddresses { for _, outbound := range r.outbounds { if outbound.PreferredAddress(address) { @@ -94,6 +94,15 @@ func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { } } } + if len(metadata.CacheIPs) > 0 { + for _, address := range metadata.CacheIPs { + for _, outbound := range r.outbounds { + if outbound.PreferredAddress(address) { + return true + } + } + } + } } return false } From 12aaa96dfae60b6ce152bb4ccf4a7fd4d011dba5 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Tue, 8 Apr 2025 17:50:29 +0800 Subject: [PATCH 17/57] Add `auto_redirect_disable_mark_mode` option for tun inbound --- option/tun.go | 1 + protocol/tun/inbound.go | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/option/tun.go b/option/tun.go index fda028b69e..8397c4ed5a 100644 --- a/option/tun.go +++ b/option/tun.go @@ -18,6 +18,7 @@ type TunInboundOptions struct { IPRoute2TableIndex int `json:"iproute2_table_index,omitempty"` IPRoute2RuleIndex int `json:"iproute2_rule_index,omitempty"` AutoRedirect bool `json:"auto_redirect,omitempty"` + AutoRedirectDisableMarkMode bool `json:"auto_redirect_disable_mark_mode,omitempty"` AutoRedirectInputMark FwMark `json:"auto_redirect_input_mark,omitempty"` AutoRedirectOutputMark FwMark `json:"auto_redirect_output_mark,omitempty"` AutoRedirectResetMark FwMark `json:"auto_redirect_reset_mark,omitempty"` diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 6f10849321..b3be5889a3 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -17,7 +17,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" @@ -253,7 +253,10 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, E.Cause(err, "initialize auto-redirect") } - if !C.IsAndroid { + if options.AutoRedirectDisableMarkMode && (len(inbound.routeRuleSet) > 0 || len(inbound.routeExcludeRuleSet) > 0) { + return nil, E.New("`auto_redirect` mark mode cannot be disabled with `route_address_set` or `route_exclude_address_set`") + } + if !C.IsAndroid && !options.AutoRedirectDisableMarkMode { inbound.tunOptions.AutoRedirectMarkMode = true err = networkManager.RegisterAutoRedirectOutputMark(inbound.tunOptions.AutoRedirectOutputMark) if err != nil { From e02c4efc8693a46e9d053db65fd6a0cc89b09f72 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 11 Jun 2025 13:45:14 +0800 Subject: [PATCH 18/57] Add fallback support for AnyTLS inbound --- docs/configuration/inbound/anytls.md | 26 ++++++++- docs/configuration/inbound/anytls.zh.md | 26 ++++++++- option/anytls.go | 6 +- protocol/anytls/inbound.go | 74 ++++++++++++++++++++++--- 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/docs/configuration/inbound/anytls.md b/docs/configuration/inbound/anytls.md index f3780119f6..b40bf2e6f1 100644 --- a/docs/configuration/inbound/anytls.md +++ b/docs/configuration/inbound/anytls.md @@ -20,7 +20,17 @@ icon: material/new-box } ], "padding_scheme": [], - "tls": {} + "tls": {}, + "fallback": { + "server": "127.0.0.1", + "server_port": 8080 + }, + "fallback_for_alpn": { + "http/1.1": { + "server": "127.0.0.1", + "server_port": 8081 + } + } } ``` @@ -59,3 +69,17 @@ Default padding scheme: #### tls TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### fallback + +!!! failure "" + + There is no evidence that GFW detects and blocks AnyTLS servers based on HTTP responses, and opening the standard http/s port on the server is a much bigger signature. + +Fallback server configuration. Disabled if `fallback` and `fallback_for_alpn` are empty. + +#### fallback_for_alpn + +Fallback server configuration for specified ALPN. + +If not empty, TLS fallback requests with ALPN not in this table will be rejected. diff --git a/docs/configuration/inbound/anytls.zh.md b/docs/configuration/inbound/anytls.zh.md index 55b6749ed1..dd8805f32a 100644 --- a/docs/configuration/inbound/anytls.zh.md +++ b/docs/configuration/inbound/anytls.zh.md @@ -20,7 +20,17 @@ icon: material/new-box } ], "padding_scheme": [], - "tls": {} + "tls": {}, + "fallback": { + "server": "127.0.0.1", + "server_port": 8080 + }, + "fallback_for_alpn": { + "http/1.1": { + "server": "127.0.0.1", + "server_port": 8081 + } + } } ``` @@ -59,3 +69,17 @@ AnyTLS 填充方案行数组。 #### tls TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 + +#### fallback + +!!! quote "" + + 没有证据表明 GFW 基于 HTTP 响应检测并阻止 AnyTLS 服务器,并且在服务器上打开标准 http/s 端口是一个更大的特征。 + +回退服务器配置。如果 `fallback` 和 `fallback_for_alpn` 为空,则禁用回退。 + +#### fallback_for_alpn + +为 ALPN 指定回退服务器配置。 + +如果不为空,ALPN 不在此列表中的 TLS 回退请求将被拒绝。 diff --git a/option/anytls.go b/option/anytls.go index 0f78526327..c1a5883510 100644 --- a/option/anytls.go +++ b/option/anytls.go @@ -5,8 +5,10 @@ import "github.com/sagernet/sing/common/json/badoption" type AnyTLSInboundOptions struct { ListenOptions InboundTLSOptionsContainer - Users []AnyTLSUser `json:"users,omitempty"` - PaddingScheme badoption.Listable[string] `json:"padding_scheme,omitempty"` + Users []AnyTLSUser `json:"users,omitempty"` + PaddingScheme badoption.Listable[string] `json:"padding_scheme,omitempty"` + Fallback *ServerOptions `json:"fallback,omitempty"` + FallbackForALPN map[string]*ServerOptions `json:"fallback_for_alpn,omitempty"` } type AnyTLSUser struct { diff --git a/protocol/anytls/inbound.go b/protocol/anytls/inbound.go index 52d773537a..0341fe1f55 100644 --- a/protocol/anytls/inbound.go +++ b/protocol/anytls/inbound.go @@ -3,6 +3,7 @@ package anytls import ( "context" "net" + "os" "strings" "github.com/sagernet/sing-box/adapter" @@ -30,11 +31,13 @@ func RegisterInbound(registry *inbound.Registry) { type Inbound struct { inbound.Adapter - tlsConfig tls.ServerConfig - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - service *anytls.Service + tlsConfig tls.ServerConfig + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service *anytls.Service + fallbackAddr M.Socksaddr + fallbackAddrTLSNextProto map[string]M.Socksaddr } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) { @@ -57,13 +60,39 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo paddingScheme = []byte(strings.Join(options.PaddingScheme, "\n")) } + var fallbackHandler N.TCPConnectionHandlerEx + if options.Fallback != nil && options.Fallback.Server != "" || len(options.FallbackForALPN) > 0 { + if options.Fallback != nil && options.Fallback.Server != "" { + inbound.fallbackAddr = options.Fallback.Build() + if !inbound.fallbackAddr.IsValid() { + return nil, E.New("invalid fallback address: ", inbound.fallbackAddr) + } + } + if len(options.FallbackForALPN) > 0 { + if inbound.tlsConfig == nil { + return nil, E.New("fallback for ALPN is not supported without TLS") + } + fallbackAddrNextProto := make(map[string]M.Socksaddr) + for nextProto, destination := range options.FallbackForALPN { + fallbackAddr := destination.Build() + if !fallbackAddr.IsValid() { + return nil, E.New("invalid fallback address for ALPN ", nextProto, ": ", fallbackAddr) + } + fallbackAddrNextProto[nextProto] = fallbackAddr + } + inbound.fallbackAddrTLSNextProto = fallbackAddrNextProto + } + fallbackHandler = adapter.NewUpstreamContextHandlerEx(inbound.fallbackConnection, nil) + } + service, err := anytls.NewService(anytls.ServiceConfig{ Users: common.Map(options.Users, func(it option.AnyTLSUser) anytls.User { return (anytls.User)(it) }), - PaddingScheme: paddingScheme, - Handler: (*inboundHandler)(inbound), - Logger: logger, + PaddingScheme: paddingScheme, + Handler: (*inboundHandler)(inbound), + FallbackHandler: fallbackHandler, + Logger: logger, }) if err != nil { return nil, err @@ -113,6 +142,35 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } } +func (h *Inbound) fallbackConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + var fallbackAddr M.Socksaddr + if len(h.fallbackAddrTLSNextProto) > 0 { + if tlsConn, loaded := common.Cast[tls.Conn](conn); loaded { + connectionState := tlsConn.ConnectionState() + if connectionState.NegotiatedProtocol != "" { + if fallbackAddr, loaded = h.fallbackAddrTLSNextProto[connectionState.NegotiatedProtocol]; !loaded { + h.logger.DebugContext(ctx, "process connection from ", metadata.Source, ": fallback disabled for ALPN: ", connectionState.NegotiatedProtocol) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + } + } + } + if !fallbackAddr.IsValid() { + if !h.fallbackAddr.IsValid() { + h.logger.DebugContext(ctx, "process connection from ", metadata.Source, ": fallback disabled by default") + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + fallbackAddr = h.fallbackAddr + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + metadata.Destination = fallbackAddr + h.logger.InfoContext(ctx, "fallback connection to ", fallbackAddr) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + type inboundHandler Inbound func (h *inboundHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { From 8ff1ab9f3884969276542d4dbf2a9e6517ba6b68 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 11 Apr 2025 00:55:37 +0800 Subject: [PATCH 19/57] clash-api: Add support for upgrading external UI automatically --- adapter/experimental.go | 2 + experimental/cachefile/cache.go | 43 ++++++++++-- experimental/clashapi/api_meta_upgrade.go | 5 +- experimental/clashapi/server.go | 56 +++++++++++++-- experimental/clashapi/server_resources.go | 84 ++++++++++++++++++----- option/experimental.go | 1 + 6 files changed, 160 insertions(+), 31 deletions(-) diff --git a/adapter/experimental.go b/adapter/experimental.go index 1bd8d2d928..ee4de1cb58 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -55,6 +55,8 @@ type CacheFile interface { StoreGroupExpand(group string, expand bool) error LoadRuleSet(tag string) *SavedBinary SaveRuleSet(tag string, set *SavedBinary) error + LoadExternalUI(tag string) *SavedBinary + SaveExternalUI(tag string, info *SavedBinary) error } type SavedBinary struct { diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index ac2d700280..fbaf6bc805 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -19,16 +19,18 @@ import ( ) var ( - bucketSelected = []byte("selected") - bucketExpand = []byte("group_expand") - bucketMode = []byte("clash_mode") - bucketRuleSet = []byte("rule_set") + bucketSelected = []byte("selected") + bucketExpand = []byte("group_expand") + bucketMode = []byte("clash_mode") + bucketRuleSet = []byte("rule_set") + bucketExternalUI = []byte("external_ui") bucketNameList = []string{ string(bucketSelected), string(bucketExpand), string(bucketMode), string(bucketRuleSet), + string(bucketExternalUI), string(bucketRDRC), } @@ -359,3 +361,36 @@ func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedBinary) error { return bucket.Put([]byte(tag), setBinary) }) } + +func (c *CacheFile) LoadExternalUI(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketExternalUI) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveExternalUI(tag string, info *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketExternalUI) + if err != nil { + return err + } + setBinary, err := info.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/clashapi/api_meta_upgrade.go b/experimental/clashapi/api_meta_upgrade.go index df70088edf..376e31a11a 100644 --- a/experimental/clashapi/api_meta_upgrade.go +++ b/experimental/clashapi/api_meta_upgrade.go @@ -23,14 +23,13 @@ func updateExternalUI(server *Server) func(w http.ResponseWriter, r *http.Reques return } server.logger.Info("upgrading external UI") - err := server.downloadExternalUI() + err := server.checkAndDownloadExternalUI(true) if err != nil { - server.logger.Error(E.Cause(err, "upgrade external ui")) + server.logger.Error(E.Cause(err, "upgrade external UI")) render.Status(r, http.StatusInternalServerError) render.JSON(w, r, newError(err.Error())) return } - server.logger.Info("updated external UI") render.JSON(w, r, render.M{"status": "ok"}) } } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index c36611821e..a8c6a21e3c 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -60,11 +60,23 @@ type Server struct { externalUI string externalUIDownloadURL string externalUIDownloadDetour string + externalUIUpdateInterval time.Duration + cacheFile adapter.CacheFile + lastEtag string + lastUpdated time.Time + ticker *time.Ticker } func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { trafficManager := trafficontrol.NewManager() chiRouter := chi.NewRouter() + updateInterval := time.Duration(options.ExternalUIUpdateInterval) + if updateInterval <= 0 { + updateInterval = 0 + } + if updateInterval > 0 && updateInterval < time.Hour { + updateInterval = time.Hour + } s := &Server{ ctx: ctx, router: service.FromContext[adapter.Router](ctx), @@ -82,6 +94,8 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op externalController: options.ExternalController != "", externalUIDownloadURL: options.ExternalUIDownloadURL, externalUIDownloadDetour: options.ExternalUIDownloadDetour, + externalUIUpdateInterval: updateInterval, + cacheFile: service.FromContext[adapter.CacheFile](ctx), } s.urlTestHistory = service.FromContext[adapter.URLTestHistoryStorage](ctx) if s.urlTestHistory == nil { @@ -148,9 +162,8 @@ func (s *Server) Name() string { func (s *Server) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStateStart: - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - mode := cacheFile.LoadMode() + if s.cacheFile != nil { + mode := s.cacheFile.LoadMode() if common.Any(s.modeList, func(it string) bool { return strings.EqualFold(it, mode) }) { @@ -159,7 +172,18 @@ func (s *Server) Start(stage adapter.StartStage) error { } case adapter.StartStateStarted: if s.externalController { - s.checkAndDownloadExternalUI() + if s.externalUI != "" && s.externalUIUpdateInterval != 0 { + if s.cacheFile != nil { + if savedExternalUI := s.cacheFile.LoadExternalUI("ExternalUI"); savedExternalUI != nil { + s.lastUpdated = savedExternalUI.LastUpdated + s.lastEtag = savedExternalUI.LastEtag + } + } + } + s.checkAndDownloadExternalUI(false) + if s.externalUIUpdateInterval != 0 && !s.lastUpdated.IsZero() { + go s.loopUpdate() + } var ( listener net.Listener err error @@ -188,7 +212,26 @@ func (s *Server) Start(stage adapter.StartStage) error { return nil } +func (s *Server) loopUpdate() { + s.ticker = time.NewTicker(s.externalUIUpdateInterval) + if time.Since(s.lastUpdated) > s.externalUIUpdateInterval { + s.checkAndDownloadExternalUI(true) + } + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.ticker.C: + s.checkAndDownloadExternalUI(true) + } + } +} + func (s *Server) Close() error { + if s.ticker != nil { + s.ticker.Stop() + } return common.Close( common.PtrOrNil(s.httpServer), s.trafficManager, @@ -225,9 +268,8 @@ func (s *Server) SetMode(newMode string) { s.modeUpdateHook.Emit(struct{}{}) } s.dnsRouter.ClearCache() - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - err := cacheFile.StoreMode(newMode) + if s.cacheFile != nil { + err := s.cacheFile.StoreMode(newMode) if err != nil { s.logger.Error(E.Cause(err, "save mode")) } diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go index ad9fff5369..8c9ddf1e1a 100644 --- a/experimental/clashapi/server_resources.go +++ b/experimental/clashapi/server_resources.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -20,20 +21,29 @@ import ( "github.com/sagernet/sing/service/filemanager" ) -func (s *Server) checkAndDownloadExternalUI() { +func (s *Server) checkAndDownloadExternalUI(update bool) error { if s.externalUI == "" { - return + return nil } entries, err := os.ReadDir(s.externalUI) if err != nil { - os.MkdirAll(s.externalUI, 0o755) + filemanager.MkdirAll(s.ctx, s.externalUI, 0o755) + } + if len(entries) != 0 && s.lastUpdated.IsZero() { + info, _ := os.Stat(s.externalUI) + s.lastUpdated = info.ModTime() } - if len(entries) == 0 { + if len(entries) == 0 || update { + if len(entries) == 0 && s.lastEtag != "" { + s.lastEtag = "" + } err = s.downloadExternalUI() if err != nil { - s.logger.Error("download external ui error: ", err) + s.logger.Error("download external UI error: ", err) + return err } } + return nil } func (s *Server) downloadExternalUI() error { @@ -54,7 +64,7 @@ func (s *Server) downloadExternalUI() error { outbound := s.outbound.Default() detour = outbound } - s.logger.Info("downloading external ui using outbound/", detour.Type(), "[", detour.Tag(), "]") + s.logger.Info("downloading external UI using outbound/", detour.Type(), "[", detour.Tag(), "]") httpClient := &http.Client{ Transport: &http.Transport{ ForceAttemptHTTP2: true, @@ -68,20 +78,60 @@ func (s *Server) downloadExternalUI() error { }, }, } - defer httpClient.CloseIdleConnections() - response, err := httpClient.Get(downloadURL) + request, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return err } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return E.New("download external ui failed: ", response.Status) + if s.lastEtag != "" { + request.Header.Set("If-None-Match", s.lastEtag) + } + response, err := httpClient.Do(request.WithContext(s.ctx)) + if err != nil { + return err } + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.lastUpdated = time.Now() + os.Chtimes(s.externalUI, s.lastUpdated, s.lastUpdated) + if s.cacheFile != nil { + if savedExternalUI := s.cacheFile.LoadExternalUI("ExternalUI"); savedExternalUI != nil { + savedExternalUI.LastUpdated = s.lastUpdated + err = s.cacheFile.SaveExternalUI("ExternalUI", savedExternalUI) + if err != nil { + s.logger.Error("save external UI updated time: ", err) + return nil + } + } + } + s.logger.Info("update external UI: not modified") + return nil + default: + return E.New("download external UI failed: ", response.Status) + } + defer response.Body.Close() + removeAllInDirectory(s.ctx, s.externalUI) err = s.downloadZIP(response.Body, s.externalUI) if err != nil { - removeAllInDirectory(s.externalUI) + removeAllInDirectory(s.ctx, s.externalUI) + return err + } + eTagHeader := response.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader } - return err + s.lastUpdated = time.Now() + if s.cacheFile != nil { + err = s.cacheFile.SaveExternalUI("ExternalUI", &adapter.SavedBinary{ + LastEtag: s.lastEtag, + LastUpdated: s.lastUpdated, + }) + if err != nil { + s.logger.Error("save external UI cache file: ", err) + } + } + s.logger.Info("updated external UI") + return nil } func (s *Server) downloadZIP(body io.Reader, output string) error { @@ -89,7 +139,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if err != nil { return err } - defer os.Remove(tempFile.Name()) + defer filemanager.Remove(s.ctx, tempFile.Name()) _, err = io.Copy(tempFile, body) tempFile.Close() if err != nil { @@ -113,7 +163,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if len(pathElements) > 1 { saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) } - err = os.MkdirAll(saveDirectory, 0o755) + err = filemanager.MkdirAll(s.ctx, saveDirectory, 0o755) if err != nil { return err } @@ -140,13 +190,13 @@ func downloadZIPEntry(ctx context.Context, zipFile *zip.File, savePath string) e return common.Error(io.Copy(saveFile, reader)) } -func removeAllInDirectory(directory string) { +func removeAllInDirectory(ctx context.Context, directory string) { dirEntries, err := os.ReadDir(directory) if err != nil { return } for _, dirEntry := range dirEntries { - os.RemoveAll(filepath.Join(directory, dirEntry.Name())) + filemanager.RemoveAll(ctx, filepath.Join(directory, dirEntry.Name())) } } diff --git a/option/experimental.go b/option/experimental.go index bf0df9e78c..518568b909 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -23,6 +23,7 @@ type ClashAPIOptions struct { ExternalUI string `json:"external_ui,omitempty"` ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` + ExternalUIUpdateInterval badoption.Duration `json:"external_ui_update_interval,omitempty"` Secret string `json:"secret,omitempty"` DefaultMode string `json:"default_mode,omitempty"` ModeList []string `json:"-"` From a7c1c040bd487b4c548c7988947937f7184e0f70 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 4 Mar 2026 18:13:00 +0800 Subject: [PATCH 20/57] clash-api: Add DNS rules support --- adapter/dns.go | 1 + dns/router.go | 4 ++++ experimental/clashapi/rules.go | 17 +++++++++++------ experimental/clashapi/server.go | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index 8f065e2e82..cfcfffbc66 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -20,6 +20,7 @@ type DNSRouter interface { Lookup(ctx context.Context, domain string, options DNSQueryOptions) ([]netip.Addr, error) ClearCache() LookupReverseMapping(ip netip.Addr) (string, bool) + Rules() []DNSRule ResetNetwork() } diff --git a/dns/router.go b/dns/router.go index 031322fb10..6e874318ae 100644 --- a/dns/router.go +++ b/dns/router.go @@ -449,6 +449,10 @@ func addressLimitResponseCheck(rule adapter.DNSRule, metadata *adapter.InboundCo } } +func (r *Router) Rules() []adapter.DNSRule { + return r.rules +} + func (r *Router) ClearCache() { r.client.ClearCache() if r.platformInterface != nil { diff --git a/experimental/clashapi/rules.go b/experimental/clashapi/rules.go index bc8fbb2bba..b99b8ab99c 100644 --- a/experimental/clashapi/rules.go +++ b/experimental/clashapi/rules.go @@ -9,9 +9,9 @@ import ( "github.com/go-chi/render" ) -func ruleRouter(router adapter.Router) http.Handler { +func ruleRouter(router adapter.Router, dnsRouter adapter.DNSRouter) http.Handler { r := chi.NewRouter() - r.Get("/", getRules(router)) + r.Get("/", getRules(router, dnsRouter)) return r } @@ -21,12 +21,17 @@ type Rule struct { Proxy string `json:"proxy"` } -func getRules(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { +func getRules(router adapter.Router, dnsRouter adapter.DNSRouter) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - rawRules := router.Rules() - var rules []Rule - for _, rule := range rawRules { + for _, rule := range dnsRouter.Rules() { + rules = append(rules, Rule{ + Type: rule.Type(), + Payload: rule.String(), + Proxy: rule.Action().String(), + }) + } + for _, rule := range router.Rules() { rules = append(rules, Rule{ Type: rule.Type(), Payload: rule.String(), diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index a8c6a21e3c..b9814b719a 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -134,7 +134,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Get("/version", version) r.Mount("/configs", configRouter(s, logFactory)) r.Mount("/proxies", proxyRouter(s, s.router)) - r.Mount("/rules", ruleRouter(s.router)) + r.Mount("/rules", ruleRouter(s.router, s.dnsRouter)) r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) r.Mount("/providers/proxies", proxyProviderRouter()) r.Mount("/providers/rules", ruleProviderRouter()) From 47feb274ff62b50f6fed7fc1c2848ca855e121b8 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Mon, 2 Mar 2026 23:41:46 +0800 Subject: [PATCH 21/57] DNS: Add group transport for concurrent multi-server querying --- constant/dns.go | 1 + dns/transport/group.go | 117 ++++++++++++++++++++++ docs/configuration/dns/server/group.md | 40 ++++++++ docs/configuration/dns/server/group.zh.md | 40 ++++++++ docs/configuration/dns/server/index.md | 1 + docs/configuration/dns/server/index.zh.md | 1 + include/registry.go | 1 + mkdocs.yml | 1 + option/dns.go | 4 + 9 files changed, 206 insertions(+) create mode 100644 dns/transport/group.go create mode 100644 docs/configuration/dns/server/group.md create mode 100644 docs/configuration/dns/server/group.zh.md diff --git a/constant/dns.go b/constant/dns.go index 15d6096c78..3d15c6304d 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -28,6 +28,7 @@ const ( DNSTypeFakeIP = "fakeip" DNSTypeDHCP = "dhcp" DNSTypeTailscale = "tailscale" + DNSTypeGroup = "group" ) const ( diff --git a/dns/transport/group.go b/dns/transport/group.go new file mode 100644 index 0000000000..53d9c3a2b3 --- /dev/null +++ b/dns/transport/group.go @@ -0,0 +1,117 @@ +package transport + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*GroupTransport)(nil) + +func RegisterGroup(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.GroupDNSServerOptions](registry, C.DNSTypeGroup, NewGroup) +} + +type GroupTransport struct { + dns.TransportAdapter + + ctx context.Context + logger log.ContextLogger + serverTags []string +} + +func NewGroup(ctx context.Context, logger log.ContextLogger, tag string, options option.GroupDNSServerOptions) (adapter.DNSTransport, error) { + if len(options.Servers) == 0 { + return nil, E.New("missing servers") + } + return &GroupTransport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeGroup, tag, options.Servers), + ctx: ctx, + logger: logger, + serverTags: options.Servers, + }, nil +} + +func (t *GroupTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + transportManager := service.FromContext[adapter.DNSTransportManager](t.ctx) + if transportManager == nil { + return E.New("missing DNS transport manager") + } + for _, tag := range t.serverTags { + transport, loaded := transportManager.Transport(tag) + if !loaded { + return E.New("DNS server not found: ", tag) + } + if transport.Type() == C.DNSTypeGroup { + return E.New("group cannot contain another group: ", tag) + } + if transport.Type() == C.DNSTypeFakeIP { + return E.New("group cannot contain fakeip server: ", tag) + } + } + return nil +} + +func (t *GroupTransport) Close() error { + return nil +} + +func (t *GroupTransport) Reset() { +} + +func (t *GroupTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + transportManager := service.FromContext[adapter.DNSTransportManager](t.ctx) + if transportManager == nil { + return nil, E.New("missing DNS transport manager") + } + + type result struct { + response *mDNS.Msg + tag string + err error + } + + resultCh := make(chan result, len(t.serverTags)) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + for _, tag := range t.serverTags { + transport, loaded := transportManager.Transport(tag) + if !loaded { + resultCh <- result{nil, tag, E.New("DNS server not found: ", tag)} + continue + } + go func(transport adapter.DNSTransport, tag string) { + resp, err := transport.Exchange(ctx, message.Copy()) + resultCh <- result{resp, tag, err} + }(transport, tag) + } + + var firstErr error + for range t.serverTags { + r := <-resultCh + if r.err == nil && r.response != nil { + t.logger.DebugContext(ctx, "fastest response from ", r.tag) + return r.response, nil + } + if firstErr == nil && r.err != nil { + firstErr = r.err + } + } + + if firstErr != nil { + return nil, firstErr + } + return nil, E.New("all DNS servers failed") +} diff --git a/docs/configuration/dns/server/group.md b/docs/configuration/dns/server/group.md new file mode 100644 index 0000000000..20bc8621f9 --- /dev/null +++ b/docs/configuration/dns/server/group.md @@ -0,0 +1,40 @@ +--- +icon: material/new-box +--- + +# Group + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "group", + "tag": "dns-group", + + "servers": [ + "dns-a", + "dns-b" + ] + } + ] + } +} +``` + +### Fields + +#### servers + +==Required== + +List of DNS server tags to include in this group. + +Restrictions: + +- A group cannot contain another group. +- A group cannot contain a `fakeip` server. + +When queried, all servers in the group are queried concurrently, and the first successful response is returned. diff --git a/docs/configuration/dns/server/group.zh.md b/docs/configuration/dns/server/group.zh.md new file mode 100644 index 0000000000..4b7b3a0048 --- /dev/null +++ b/docs/configuration/dns/server/group.zh.md @@ -0,0 +1,40 @@ +--- +icon: material/new-box +--- + +# Group + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "group", + "tag": "dns-group", + + "servers": [ + "dns-a", + "dns-b" + ] + } + ] + } +} +``` + +### 字段 + +#### servers + +==必填== + +此组包含的 DNS 服务器 tag 列表。 + +限制: + +- 组内不能包含另一个组。 +- 组内不能包含 `fakeip` 类型的服务器。 + +查询时,组内所有服务器将被并发查询,最先返回的成功响应将作为结果使用。 diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md index 4f10948e58..5a70d6a059 100644 --- a/docs/configuration/dns/server/index.md +++ b/docs/configuration/dns/server/index.md @@ -42,6 +42,7 @@ The type of the DNS server. | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | | `resolved` | [Resolved](./resolved/) | +| `group` | [Group](./group/) | #### tag diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md index d6deef5a33..a48c8aeaf3 100644 --- a/docs/configuration/dns/server/index.zh.md +++ b/docs/configuration/dns/server/index.zh.md @@ -42,6 +42,7 @@ DNS 服务器的类型。 | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | | `resolved` | [Resolved](./resolved/) | +| `group` | [Group](./group/) | #### tag diff --git a/include/registry.go b/include/registry.go index f090845b51..e0511c1454 100644 --- a/include/registry.go +++ b/include/registry.go @@ -113,6 +113,7 @@ func DNSTransportRegistry() *dns.TransportRegistry { transport.RegisterUDP(registry) transport.RegisterTLS(registry) transport.RegisterHTTPS(registry) + transport.RegisterGroup(registry) hosts.RegisterTransport(registry) local.RegisterTransport(registry) fakeip.RegisterTransport(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 70edfaac43..8a12949ada 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -96,6 +96,7 @@ nav: - FakeIP: configuration/dns/server/fakeip.md - Tailscale: configuration/dns/server/tailscale.md - Resolved: configuration/dns/server/resolved.md + - Group: configuration/dns/server/group.md - DNS Rule: configuration/dns/rule.md - DNS Rule Action: configuration/dns/rule_action.md - FakeIP: configuration/dns/fakeip.md diff --git a/option/dns.go b/option/dns.go index d4688d0f0d..df8edfa630 100644 --- a/option/dns.go +++ b/option/dns.go @@ -399,6 +399,10 @@ type RemoteHTTPSDNSServerOptions struct { Headers badoption.HTTPHeader `json:"headers,omitempty"` } +type GroupDNSServerOptions struct { + Servers []string `json:"servers"` +} + type FakeIPDNSServerOptions struct { Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` From 0ae79a2b35cacc8619356dfc40c2a99c5ed6b3c9 Mon Sep 17 00:00:00 2001 From: dyhkwong <50692134+dyhkwong@users.noreply.github.com> Date: Sun, 17 Sep 2023 21:49:21 +0800 Subject: [PATCH 22/57] feature: TLS certificate pinning --- cmd/sing-box/cmd_generate_pinsha256.go | 49 +++++++++++++++++++++ common/tls/std_client.go | 59 +++++++++++++++++++++----- common/tls/utls_client.go | 55 +++++++++++++++++++++--- option/tls.go | 1 + 4 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 cmd/sing-box/cmd_generate_pinsha256.go diff --git a/cmd/sing-box/cmd_generate_pinsha256.go b/cmd/sing-box/cmd_generate_pinsha256.go new file mode 100644 index 0000000000..64b557260a --- /dev/null +++ b/cmd/sing-box/cmd_generate_pinsha256.go @@ -0,0 +1,49 @@ +package main + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "os" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandGeneratePinSHA256 = &cobra.Command{ + // openssl x509 -noout -fingerprint -sha256 -in certificate.crt + Use: "pinsha256 certificate.crt", + Short: "Generate SHA256 fingerprint for a certificate file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := generatePinSHA256(args) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGenerate.AddCommand(commandGeneratePinSHA256) +} + +func generatePinSHA256(args []string) error { + file, err := os.ReadFile(args[0]) + if err != nil { + return err + } + block, _ := pem.Decode(file) + if block == nil { + return E.New("pem decode error") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return err + } + hash := sha256.Sum256(cert.Raw) + os.Stdout.WriteString("SHA256 fingerprint: " + hex.EncodeToString(hash[:]) + "\n") + return nil +} diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 1611c83e7c..2b258e9aee 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -7,13 +7,14 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "net" "os" "strings" "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -93,6 +94,53 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres } if options.Insecure { tlsConfig.InsecureSkipVerify = options.Insecure + } else if len(options.CertificatePinSHA256) > 0 { + if len(options.CertificatePublicKeySHA256) > 0 || len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_pin_sha256 is conflict with certificate_public_key_sha256 or certificate or certificate_path") + } + fingerprint := strings.TrimSpace(strings.ReplaceAll(options.CertificatePinSHA256, ":", "")) + fpByte, err := hex.DecodeString(fingerprint) + if err != nil { + return nil, E.Cause(err, "decode fingerprint string") + } + if len(fpByte) != 32 { + return nil, E.New("fingerprint string length error, need sha256 fingerprint") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { + certs := state.PeerCertificates + for i, cert := range certs { + hash := sha256.Sum256(cert.Raw) + if bytes.Equal(fpByte, hash[:]) { + if i > 0 { + opts := x509.VerifyOptions{ + Roots: x509.NewCertPool(), + Intermediates: x509.NewCertPool(), + DNSName: serverName, + } + if tlsConfig.Time != nil { + opts.CurrentTime = tlsConfig.Time() + } + opts.Roots.AddCert(certs[i]) + for _, cert := range certs[1 : i+1] { + opts.Intermediates.AddCert(cert) + } + _, err := certs[0].Verify(opts) + return err + } + return nil + } + } + return E.New("certificate fingerprint mismatch") + } + } else if len(options.CertificatePublicKeySHA256) > 0 { + if len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) + } } else if options.DisableSNI { tlsConfig.InsecureSkipVerify = true tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { @@ -111,15 +159,6 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres return err } } - if len(options.CertificatePublicKeySHA256) > 0 { - if len(options.Certificate) > 0 || options.CertificatePath != "" { - return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") - } - tlsConfig.InsecureSkipVerify = true - tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { - return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) - } - } if len(options.ALPN) > 0 { tlsConfig.NextProtos = options.ALPN } diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index 941192ba16..e4e4578a7d 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -3,9 +3,12 @@ package tls import ( + "bytes" "context" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "math/rand" "net" "os" @@ -13,7 +16,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" @@ -161,13 +164,46 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre } if options.Insecure { tlsConfig.InsecureSkipVerify = options.Insecure - } else if options.DisableSNI { - if options.Reality != nil && options.Reality.Enabled { - return nil, E.New("disable_sni is unsupported in reality") + } else if len(options.CertificatePinSHA256) > 0 { + if len(options.CertificatePublicKeySHA256) > 0 || len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_pin_sha256 is conflict with certificate_public_key_sha256 or certificate or certificate_path") } - tlsConfig.InsecureServerNameToVerify = serverName - } - if len(options.CertificatePublicKeySHA256) > 0 { + fingerprint := strings.TrimSpace(strings.ReplaceAll(options.CertificatePinSHA256, ":", "")) + fpByte, err := hex.DecodeString(fingerprint) + if err != nil { + return nil, E.Cause(err, "decode fingerprint string") + } + if len(fpByte) != 32 { + return nil, E.New("fingerprint string length error, need sha256 fingerprint") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyConnection = func(state utls.ConnectionState) error { + certs := state.PeerCertificates + for i, cert := range certs { + hash := sha256.Sum256(cert.Raw) + if bytes.Equal(fpByte, hash[:]) { + if i > 0 { + opts := x509.VerifyOptions{ + Roots: x509.NewCertPool(), + Intermediates: x509.NewCertPool(), + DNSName: serverName, + } + if tlsConfig.Time != nil { + opts.CurrentTime = tlsConfig.Time() + } + opts.Roots.AddCert(certs[i]) + for _, cert := range certs[1 : i+1] { + opts.Intermediates.AddCert(cert) + } + _, err := certs[0].Verify(opts) + return err + } + return nil + } + } + return E.New("certificate fingerprint mismatch") + } + } else if len(options.CertificatePublicKeySHA256) > 0 { if len(options.Certificate) > 0 || options.CertificatePath != "" { return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") } @@ -175,6 +211,11 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) } + } else if options.DisableSNI { + if options.Reality != nil && options.Reality.Enabled { + return nil, E.New("disable_sni is unsupported in reality") + } + tlsConfig.InsecureServerNameToVerify = serverName } if len(options.ALPN) > 0 { tlsConfig.NextProtos = options.ALPN diff --git a/option/tls.go b/option/tls.go index 60343a15f1..e1267bb52e 100644 --- a/option/tls.go +++ b/option/tls.go @@ -111,6 +111,7 @@ type OutboundTLSOptions struct { ClientCertificatePath string `json:"client_certificate_path,omitempty"` ClientKey badoption.Listable[string] `json:"client_key,omitempty"` ClientKeyPath string `json:"client_key_path,omitempty"` + CertificatePinSHA256 string `json:"certificate_pin_sha256,omitempty"` Fragment bool `json:"fragment,omitempty"` FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` RecordFragment bool `json:"record_fragment,omitempty"` From 8e222c7db148c27e47c11997662ef54dbb9ba62b Mon Sep 17 00:00:00 2001 From: yelnoo Date: Mon, 14 Apr 2025 07:12:17 +0800 Subject: [PATCH 23/57] clash-api: Add restart support --- experimental/clashapi/restart.go | 64 ++++++++++++++++++++++++++++++++ experimental/clashapi/server.go | 4 ++ 2 files changed, 68 insertions(+) create mode 100644 experimental/clashapi/restart.go diff --git a/experimental/clashapi/restart.go b/experimental/clashapi/restart.go new file mode 100644 index 0000000000..03c1081048 --- /dev/null +++ b/experimental/clashapi/restart.go @@ -0,0 +1,64 @@ +package clashapi + +import ( + "context" + "net/http" + "os" + "os/exec" + "runtime" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func restartRouter(ctx context.Context, logFactory log.Factory) http.Handler { + r := chi.NewRouter() + r.Post("/", restart(ctx, logFactory)) + return r +} + +func restart(ctx context.Context, logFactory log.Factory) func(w http.ResponseWriter, r *http.Request) { + restartExecutable := func(execPath string) { + inbound := service.FromContext[adapter.InboundManager](ctx) + dnsTransport := service.FromContext[adapter.DNSTransportManager](ctx) + common.Close(inbound, dnsTransport) + var err error + logger := logFactory.Logger() + logger.Info("sing-box restarting") + if runtime.GOOS == "windows" { + cmd := exec.Command(execPath, os.Args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Start() + if err != nil { + logger.Error("sing-box restarting: ", err) + } + + os.Exit(0) + } + + err = syscall.Exec(execPath, os.Args, os.Environ()) + if err != nil { + logger.Error("sing-box restarting: ", err) + } + } + return func(w http.ResponseWriter, r *http.Request) { + execPath, err := os.Executable() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + + go restartExecutable(execPath) + + render.JSON(w, r, render.M{"status": "ok"}) + } +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index b9814b719a..caf9ea3723 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -143,6 +143,10 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Mount("/cache", cacheRouter(ctx)) r.Mount("/dns", dnsRouter(s.dnsRouter)) + if service.FromContext[adapter.PlatformInterface](ctx) == nil { + r.Mount("/restart", restartRouter(ctx, logFactory)) + } + s.setupMetaAPI(r) }) if options.ExternalUI != "" { From 80631cb1fb00c92fcccc8e350c572035a0753941 Mon Sep 17 00:00:00 2001 From: yelnoo Date: Wed, 12 Mar 2025 07:56:09 +0800 Subject: [PATCH 24/57] add outbound provider Co-authored-by: jebbs Co-authored-by: PuerNya --- .gitignore | 1 + README.md | 4 + adapter/experimental.go | 2 + adapter/provider.go | 51 ++ adapter/provider/adapter.go | 267 +++++++ adapter/provider/manager.go | 157 ++++ adapter/provider/registry.go | 72 ++ box.go | 47 +- common/interrupt/context.go | 10 + common/interrupt/group.go | 11 +- constant/provider.go | 20 + docs/configuration/index.md | 2 + docs/configuration/index.zh.md | 2 + docs/configuration/outbound/selector.md | 29 +- docs/configuration/outbound/selector.zh.md | 25 +- docs/configuration/outbound/urltest.md | 28 +- docs/configuration/outbound/urltest.zh.md | 27 +- docs/configuration/provider/index.md | 130 ++++ docs/configuration/provider/index.zh.md | 130 ++++ experimental/cachefile/cache.go | 33 + experimental/clashapi/provider.go | 97 ++- experimental/clashapi/server.go | 4 +- experimental/libbox/config.go | 2 +- go.mod | 2 +- include/registry.go | 15 +- mkdocs.yml | 3 + option/group.go | 16 +- option/options.go | 1 + option/provider.go | 75 ++ protocol/group/selector.go | 155 +++- protocol/group/urltest.go | 117 ++- provider/local/lcoal.go | 129 ++++ provider/parser/clash.go | 843 +++++++++++++++++++++ provider/parser/link.go | 662 ++++++++++++++++ provider/parser/parser.go | 27 + provider/parser/raw.go | 49 ++ provider/parser/sing_box.go | 58 ++ provider/parser/sip008.go | 53 ++ provider/remote/remote.go | 337 ++++++++ 39 files changed, 3598 insertions(+), 95 deletions(-) create mode 100644 adapter/provider.go create mode 100644 adapter/provider/adapter.go create mode 100644 adapter/provider/manager.go create mode 100644 adapter/provider/registry.go create mode 100644 constant/provider.go create mode 100644 docs/configuration/provider/index.md create mode 100644 docs/configuration/provider/index.zh.md create mode 100644 option/provider.go create mode 100644 provider/local/lcoal.go create mode 100644 provider/parser/clash.go create mode 100644 provider/parser/link.go create mode 100644 provider/parser/parser.go create mode 100644 provider/parser/raw.go create mode 100644 provider/parser/sing_box.go create mode 100644 provider/parser/sip008.go create mode 100644 provider/remote/remote.go diff --git a/.gitignore b/.gitignore index d2b74d08cd..ccf3310e64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.idea/ +/.vscode/ /vendor/ /*.json /*.srs diff --git a/README.md b/README.md index 90be2a83a7..afc0fd3778 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ The universal proxy platform. https://sing-box.sagernet.org +For extended features + +- Providers: [中文](./docs/configuration/provider/index.zh.md), [English](./docs/configuration/provider/index.md) + ## License ``` diff --git a/adapter/experimental.go b/adapter/experimental.go index ee4de1cb58..7f428573f6 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -57,6 +57,8 @@ type CacheFile interface { SaveRuleSet(tag string, set *SavedBinary) error LoadExternalUI(tag string) *SavedBinary SaveExternalUI(tag string, info *SavedBinary) error + LoadSubscription(tag string) *SavedBinary + SaveSubscription(tag string, sub *SavedBinary) error } type SavedBinary struct { diff --git a/adapter/provider.go b/adapter/provider.go new file mode 100644 index 0000000000..0bb8886067 --- /dev/null +++ b/adapter/provider.go @@ -0,0 +1,51 @@ +package adapter + +import ( + "context" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/x/list" +) + +type Provider interface { + Type() string + Tag() string + Outbounds() []Outbound + Outbound(tag string) (Outbound, bool) + UpdatedAt() time.Time + HealthCheck(ctx context.Context) (map[string]uint16, error) + RegisterCallback(callback ProviderUpdateCallback) *list.Element[ProviderUpdateCallback] + UnregisterCallback(element *list.Element[ProviderUpdateCallback]) +} + +type ProviderUpdater interface { + Update() error +} + +type ProviderSubscriptionInfo interface { + SubscriptionInfo() SubscriptionInfo +} + +type ProviderRegistry interface { + option.ProviderOptionsRegistry + CreateProvider(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) (Provider, error) +} + +type ProviderManager interface { + Lifecycle + Providers() []Provider + Get(tag string) (Provider, bool) + Remove(tag string) error + Create(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) error +} + +type SubscriptionInfo struct { + Upload int64 + Download int64 + Total int64 + Expire int64 +} + +type ProviderUpdateCallback = func(tag string) error diff --git a/adapter/provider/adapter.go b/adapter/provider/adapter.go new file mode 100644 index 0000000000..3c55783e80 --- /dev/null +++ b/adapter/provider/adapter.go @@ -0,0 +1,267 @@ +package provider + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/batch" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" +) + +type Adapter struct { + ctx context.Context + outbound adapter.OutboundManager + router adapter.Router + logFactory log.Factory + logger log.ContextLogger + providerType string + providerTag string + outbounds []adapter.Outbound + outboundsByTag map[string]adapter.Outbound + ticker *time.Ticker + checking atomic.Bool + history adapter.URLTestHistoryStorage + callbackAccess sync.Mutex + callbacks list.List[adapter.ProviderUpdateCallback] + + link string + enabled bool + timeout time.Duration + interval time.Duration +} + +func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.OutboundManager, logFactory log.Factory, logger log.ContextLogger, providerTag string, providerType string, options option.ProviderHealthCheckOptions) Adapter { + timeout := time.Duration(options.Timeout) + if timeout == 0 { + timeout = 3 * time.Second + } + interval := time.Duration(options.Interval) + if interval == 0 { + interval = 10 * time.Minute + } + if interval < time.Minute { + interval = time.Minute + } + return Adapter{ + ctx: ctx, + outbound: outbound, + router: router, + logFactory: logFactory, + logger: logger, + providerType: providerType, + providerTag: providerTag, + + enabled: options.Enabled, + link: options.URL, + timeout: timeout, + interval: interval, + } +} + +func (a *Adapter) Start() error { + a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx) + if a.history == nil { + if clashServer := service.FromContext[adapter.ClashServer](a.ctx); clashServer != nil { + a.history = clashServer.HistoryStorage() + } else { + a.history = urltest.NewHistoryStorage() + } + } + go a.loopCheck() + return nil +} + +func (a *Adapter) Type() string { + return a.providerType +} + +func (a *Adapter) Tag() string { + return a.providerTag +} + +func (a *Adapter) Outbounds() []adapter.Outbound { + return a.outbounds +} + +func (a *Adapter) Outbound(tag string) (adapter.Outbound, bool) { + if a.outboundsByTag == nil { + return nil, false + } + detour, ok := a.outboundsByTag[tag] + return detour, ok +} + +func (a *Adapter) UpdateOutbounds(oldOpts []option.Outbound, newOpts []option.Outbound) { + a.removeUseless(newOpts) + var ( + oldOptByTag = make(map[string]option.Outbound) + outbounds = make([]adapter.Outbound, 0, len(newOpts)) + outboundsByTag = make(map[string]adapter.Outbound) + ) + for _, opt := range oldOpts { + oldOptByTag[opt.Tag] = opt + } + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + outbound, exist := a.outbound.Outbound(tag) + if !exist || !reflect.DeepEqual(opt, oldOptByTag[opt.Tag]) { + err := a.outbound.Create( + adapter.WithContext(a.ctx, &adapter.InboundContext{ + Outbound: tag, + }), + a.router, + a.logFactory.NewLogger(F.ToString("outbound/", opt.Type, "[", tag, "]")), + tag, + opt.Type, + opt.Options, + ) + if err != nil { + a.logger.Warn(err, " in ", tag, ", skip create this outbound") + continue + } + outbound, _ = a.outbound.Outbound(tag) + } + outbounds = append(outbounds, outbound) + outboundsByTag[tag] = outbound + } + if a.enabled && a.history != nil { + go a.HealthCheck(a.ctx) + } + a.outbounds = outbounds + a.outboundsByTag = outboundsByTag +} + +func (a *Adapter) HealthCheck(ctx context.Context) (map[string]uint16, error) { + if a.ticker != nil { + a.ticker.Reset(a.interval) + } + return a.healthcheck(ctx) +} + +func (a *Adapter) RegisterCallback(callback adapter.ProviderUpdateCallback) *list.Element[adapter.ProviderUpdateCallback] { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + return a.callbacks.PushBack(callback) +} + +func (a *Adapter) UnregisterCallback(element *list.Element[adapter.ProviderUpdateCallback]) { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + a.callbacks.Remove(element) +} + +func (a *Adapter) UpdateGroups() { + for element := a.callbacks.Front(); element != nil; element = element.Next() { + element.Value(a.providerTag) + } +} + +func (a *Adapter) Close() error { + if a.ticker != nil { + a.ticker.Stop() + } + outbounds := a.outbounds + a.outbounds = nil + var err error + for _, ob := range outbounds { + if err2 := a.outbound.Remove(ob.Tag()); err2 != nil { + err = E.Append(err, err2, func(err error) error { + return E.Cause(err, "close outbound [", ob.Tag(), "]") + }) + } + } + return err +} + +func (a *Adapter) loopCheck() { + if !a.enabled { + return + } + a.ticker = time.NewTicker(a.interval) + a.healthcheck(a.ctx) + for { + select { + case <-a.ctx.Done(): + return + case <-a.ticker.C: + a.healthcheck(a.ctx) + } + } +} + +func (a *Adapter) healthcheck(ctx context.Context) (map[string]uint16, error) { + result := make(map[string]uint16) + if a.checking.Swap(true) { + return result, nil + } + defer a.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + var resultAccess sync.Mutex + checked := make(map[string]bool) + for _, detour := range a.outbounds { + tag := detour.Tag() + if checked[tag] { + continue + } + checked[tag] = true + b.Go(tag, func() (any, error) { + ctx, cancel := context.WithTimeout(a.ctx, a.timeout) + defer cancel() + t, err := urltest.URLTest(ctx, a.link, detour) + if err != nil { + a.logger.Debug("outbound ", tag, " unavailable: ", err) + a.history.DeleteURLTestHistory(tag) + } else { + a.logger.Debug("outbound ", tag, " available: ", t, "ms") + a.history.StoreURLTestHistory(tag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + return result, nil +} + +func (a *Adapter) removeUseless(newOpts []option.Outbound) { + if len(a.outbounds) == 0 { + return + } + exists := make(map[string]bool) + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + exists[tag] = true + } + for _, opt := range a.outbounds { + if !exists[opt.Tag()] { + if err := a.outbound.Remove(opt.Tag()); err != nil { + a.logger.Error(err, "close outbound [", opt.Tag(), "]") + } + } + } +} diff --git a/adapter/provider/manager.go b/adapter/provider/manager.go new file mode 100644 index 0000000000..563df8dac5 --- /dev/null +++ b/adapter/provider/manager.go @@ -0,0 +1,157 @@ +package provider + +import ( + "context" + "io" + "os" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.ProviderManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.ProviderRegistry + access sync.Mutex + started bool + stage adapter.StartStage + providers []adapter.Provider + providerByTag map[string]adapter.Provider + wg sync.WaitGroup +} + +func NewManager(logger logger.ContextLogger, registry adapter.ProviderRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + providerByTag: make(map[string]adapter.Provider), + } +} + +func (m *Manager) Initialize() { +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + providers := m.providers + m.access.Unlock() + for _, provider := range providers { + err := adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + return nil +} + +func (m *Manager) Close() error { + monitor := taskmonitor.New(m.logger, C.StopTimeout) + m.access.Lock() + if !m.started { + m.access.Unlock() + return nil + } + m.started = false + providers := m.providers + m.providers = nil + m.access.Unlock() + var err error + for _, provider := range providers { + if closer, isCloser := provider.(io.Closer); isCloser { + monitor.Start("close provider/", provider.Type(), "[", provider.Tag(), "]") + err = E.Append(err, closer.Close(), func(err error) error { + return E.Cause(err, "close provider/", provider.Type(), "[", provider.Tag(), "]") + }) + monitor.Finish() + } + } + return nil +} + +func (m *Manager) Providers() []adapter.Provider { + m.access.Lock() + defer m.access.Unlock() + return m.providers +} + +func (m *Manager) Get(tag string) (adapter.Provider, bool) { + m.access.Lock() + provider, found := m.providerByTag[tag] + m.access.Unlock() + return provider, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + provider, found := m.providerByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.providerByTag, tag) + index := common.Index(m.providers, func(it adapter.Provider) bool { + return it == provider + }) + if index == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:index], m.providers[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return common.Close(provider) + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) error { + if tag == "" { + return os.ErrInvalid + } + + provider, err := m.registry.CreateProvider(ctx, router, logFactory, tag, providerType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + for _, stage := range adapter.ListStartStages { + err = adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + } + if existsProvider, loaded := m.providerByTag[tag]; loaded { + if m.started { + err = common.Close(existsProvider) + if err != nil { + return E.Cause(err, "close provider", provider.Type(), "[", existsProvider.Tag(), "]") + } + } + existsIndex := common.Index(m.providers, func(it adapter.Provider) bool { + return it == existsProvider + }) + if existsIndex == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...) + } + m.providers = append(m.providers, provider) + m.providerByTag[tag] = provider + return nil +} diff --git a/adapter/provider/registry.go b/adapter/provider/registry.go new file mode 100644 index 0000000000..5a48475452 --- /dev/null +++ b/adapter/provider/registry.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options T) (adapter.Provider, error) + +func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) { + registry.register(providerType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, rawOptions any) (adapter.Provider, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logFactory, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.ProviderRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options any) (adapter.Provider, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructors map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructors: make(map[string]constructorFunc), + } +} + +func (r *Registry) CreateOptions(providerType string) (any, bool) { + r.access.Lock() + defer r.access.Unlock() + optionsConstructor, loaded := r.optionsType[providerType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (r *Registry) CreateProvider(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) (adapter.Provider, error) { + r.access.Lock() + defer r.access.Unlock() + constructor, loaded := r.constructors[providerType] + if !loaded { + return nil, E.New("provider type not found: '" + providerType + "'") + } + return constructor(ctx, router, logFactory, tag, options) +} + +func (r *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + r.access.Lock() + defer r.access.Unlock() + r.optionsType[providerType] = optionsConstructor + r.constructors[providerType] = constructor +} diff --git a/box.go b/box.go index fe116b3175..2701ba2835 100644 --- a/box.go +++ b/box.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" boxService "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/common/certificate" "github.com/sagernet/sing-box/common/dialer" @@ -44,6 +45,7 @@ type Box struct { endpoint *endpoint.Manager inbound *inbound.Manager outbound *outbound.Manager + provider *provider.Manager service *boxService.Manager dnsTransport *dns.TransportManager dnsRouter *dns.Router @@ -62,6 +64,7 @@ type Options struct { func Context( ctx context.Context, inboundRegistry adapter.InboundRegistry, + providerRegistry adapter.ProviderRegistry, outboundRegistry adapter.OutboundRegistry, endpointRegistry adapter.EndpointRegistry, dnsTransportRegistry adapter.DNSTransportRegistry, @@ -72,6 +75,11 @@ func Context( ctx = service.ContextWith[option.InboundOptionsRegistry](ctx, inboundRegistry) ctx = service.ContextWith[adapter.InboundRegistry](ctx, inboundRegistry) } + if service.FromContext[option.ProviderOptionsRegistry](ctx) == nil || + service.FromContext[adapter.ProviderRegistry](ctx) == nil { + ctx = service.ContextWith[option.ProviderOptionsRegistry](ctx, providerRegistry) + ctx = service.ContextWith[adapter.ProviderRegistry](ctx, providerRegistry) + } if service.FromContext[option.OutboundOptionsRegistry](ctx) == nil || service.FromContext[adapter.OutboundRegistry](ctx) == nil { ctx = service.ContextWith[option.OutboundOptionsRegistry](ctx, outboundRegistry) @@ -104,6 +112,7 @@ func New(options Options) (*Box, error) { endpointRegistry := service.FromContext[adapter.EndpointRegistry](ctx) inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + providerRegistry := service.FromContext[adapter.ProviderRegistry](ctx) dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) @@ -116,6 +125,9 @@ func New(options Options) (*Box, error) { if outboundRegistry == nil { return nil, E.New("missing outbound registry in context") } + if providerRegistry == nil { + return nil, E.New("missing provider registry in context") + } if dnsTransportRegistry == nil { return nil, E.New("missing DNS transport registry in context") } @@ -177,11 +189,13 @@ func New(options Options) (*Box, error) { endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry) inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager) outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) + providerManager := provider.NewManager(logFactory.NewLogger("provider"), providerRegistry) dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) service.MustRegister[adapter.EndpointManager](ctx, endpointManager) service.MustRegister[adapter.InboundManager](ctx, inboundManager) service.MustRegister[adapter.OutboundManager](ctx, outboundManager) + service.MustRegister[adapter.ProviderManager](ctx, providerManager) service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) service.MustRegister[adapter.ServiceManager](ctx, serviceManager) dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) @@ -272,6 +286,10 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize inbound[", i, "]") } } + options.Outbounds = append(options.Outbounds, option.Outbound{ + Tag: "Compatible", + Type: C.TypeDirect, + }) for i, outboundOptions := range options.Outbounds { var tag string if outboundOptions.Tag != "" { @@ -298,6 +316,25 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize outbound[", i, "]") } } + for i, providerOptions := range options.Providers { + var tag string + if providerOptions.Tag != "" { + tag = providerOptions.Tag + } else { + tag = F.ToString(i) + } + err = providerManager.Create( + ctx, + router, + logFactory, + tag, + providerOptions.Type, + providerOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize provider[", i, "]") + } + } for i, serviceOptions := range options.Services { var tag string if serviceOptions.Tag != "" { @@ -387,6 +424,7 @@ func New(options Options) (*Box, error) { endpoint: endpointManager, inbound: inboundManager, outbound: outboundManager, + provider: providerManager, dnsTransport: dnsTransportManager, service: serviceManager, dnsRouter: dnsRouter, @@ -450,11 +488,11 @@ func (s *Box) preStart() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.provider, s.inbound, s.endpoint, s.service) if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) if err != nil { return err } @@ -474,7 +512,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.provider, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) if err != nil { return err } @@ -482,7 +520,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.provider, s.inbound, s.endpoint, s.service) if err != nil { return err } @@ -508,6 +546,7 @@ func (s *Box) Close() error { {"service", s.service}, {"endpoint", s.endpoint}, {"inbound", s.inbound}, + {"provider", s.provider}, {"outbound", s.outbound}, {"router", s.router}, {"connection", s.connection}, diff --git a/common/interrupt/context.go b/common/interrupt/context.go index 44726b2d2b..ba91601aea 100644 --- a/common/interrupt/context.go +++ b/common/interrupt/context.go @@ -11,3 +11,13 @@ func ContextWithIsExternalConnection(ctx context.Context) context.Context { func IsExternalConnectionFromContext(ctx context.Context) bool { return ctx.Value(contextKeyIsExternalConnection{}) != nil } + +type contextKeyIsProviderConnection struct{} + +func ContextWithIsProviderConnection(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKeyIsProviderConnection{}, true) +} + +func IsProviderConnectionFromContext(ctx context.Context) bool { + return ctx.Value(contextKeyIsProviderConnection{}) != nil +} diff --git a/common/interrupt/group.go b/common/interrupt/group.go index ba2e7f739b..af95ed0239 100644 --- a/common/interrupt/group.go +++ b/common/interrupt/group.go @@ -16,23 +16,24 @@ type Group struct { type groupConnItem struct { conn io.Closer isExternal bool + isProvider bool } func NewGroup() *Group { return &Group{} } -func (g *Group) NewConn(conn net.Conn, isExternal bool) net.Conn { +func (g *Group) NewConn(conn net.Conn, isExternal, isProvider bool) net.Conn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &Conn{Conn: conn, group: g, element: item} } -func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketConn { +func (g *Group) NewPacketConn(conn net.PacketConn, isExternal, isProvider bool) net.PacketConn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &PacketConn{PacketConn: conn, group: g, element: item} } @@ -41,7 +42,7 @@ func (g *Group) Interrupt(interruptExternalConnections bool) { defer g.access.Unlock() var toDelete []*list.Element[*groupConnItem] for element := g.connections.Front(); element != nil; element = element.Next() { - if !element.Value.isExternal || interruptExternalConnections { + if !element.Value.isProvider && !element.Value.isExternal || interruptExternalConnections { element.Value.conn.Close() toDelete = append(toDelete, element) } diff --git a/constant/provider.go b/constant/provider.go new file mode 100644 index 0000000000..252b1af5c4 --- /dev/null +++ b/constant/provider.go @@ -0,0 +1,20 @@ +package constant + +const ( + ProviderTypeInline = "inline" + ProviderTypeLocal = "local" + ProviderTypeRemote = "remote" +) + +func ProviderDisplayName(providerType string) string { + switch providerType { + case ProviderTypeInline: + return "Inline" + case ProviderTypeLocal: + return "Local" + case ProviderTypeRemote: + return "Remote" + default: + return "Unknown" + } +} diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 1f6eec1375..572ab9d361 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -13,6 +13,7 @@ sing-box uses JSON for configuration files. "endpoints": [], "inbounds": [], "outbounds": [], + "providers": [], "route": {}, "services": [], "experimental": {} @@ -30,6 +31,7 @@ sing-box uses JSON for configuration files. | `endpoints` | [Endpoint](./endpoint/) | | `inbounds` | [Inbound](./inbound/) | | `outbounds` | [Outbound](./outbound/) | +| `providers` | [Provider](./provider/) | | `route` | [Route](./route/) | | `services` | [Service](./service/) | | `experimental` | [Experimental](./experimental/) | diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index 3bdc352187..e794af99b4 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -13,6 +13,7 @@ sing-box 使用 JSON 作为配置文件格式。 "endpoints": [], "inbounds": [], "outbounds": [], + "providers": [], "route": {}, "services": [], "experimental": {} @@ -30,6 +31,7 @@ sing-box 使用 JSON 作为配置文件格式。 | `endpoints` | [端点](./endpoint/) | | `inbounds` | [入站](./inbound/) | | `outbounds` | [出站](./outbound/) | +| `providers` | [提供者](./provider/) | | `route` | [路由](./route/) | | `services` | [服务](./service/) | | `experimental` | [实验性](./experimental/) | diff --git a/docs/configuration/outbound/selector.md b/docs/configuration/outbound/selector.md index ee75358f5b..a596db9e28 100644 --- a/docs/configuration/outbound/selector.md +++ b/docs/configuration/outbound/selector.md @@ -10,7 +10,14 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "default": "proxy-c", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -23,14 +30,32 @@ #### outbounds -==Required== - List of outbound tags to select. +#### providers + +List of [Provider](/configuration/provider) tags to select. + +#### use_all_providers + +Use all [Provider](/configuration/provider) to fill `outbounds`. + +#### exclude + +Exclude regular expression to filter `providers` nodes. The priority of the exclude expression is higher than the include expression. + +#### include + +Include regular expression to filter `providers` nodes. + #### default The default outbound tag. The first outbound will be used if empty. +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + #### interrupt_exist_connections Interrupt existing connections when the selected outbound has changed. diff --git a/docs/configuration/outbound/selector.zh.md b/docs/configuration/outbound/selector.zh.md index ffe2d70ae1..092a5559a8 100644 --- a/docs/configuration/outbound/selector.zh.md +++ b/docs/configuration/outbound/selector.zh.md @@ -10,7 +10,14 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "default": "proxy-c", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -23,14 +30,28 @@ #### outbounds -==必填== - 用于选择的出站标签列表。 +#### providers + +用于选择的[订阅](/zh/configuration/provider)标签列表。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + #### default 默认的出站标签。默认使用第一个出站。 +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + #### interrupt_exist_connections 当选定的出站发生更改时,中断现有连接。 diff --git a/docs/configuration/outbound/urltest.md b/docs/configuration/outbound/urltest.md index f4b3b0aa8e..bdff4bf704 100644 --- a/docs/configuration/outbound/urltest.md +++ b/docs/configuration/outbound/urltest.md @@ -10,10 +10,17 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "url": "", "interval": "", - "tolerance": 0, + "tolerance": 50, "idle_timeout": "", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -22,10 +29,20 @@ #### outbounds -==Required== - List of outbound tags to test. +#### providers + +List of [Provider](/configuration/provider) tags to test. + +#### exclude + +Exclude regular expression to filter `providers` nodes. + +#### include + +Include regular expression to filter `providers` nodes. + #### url The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. @@ -42,8 +59,13 @@ The test tolerance in milliseconds. `50` will be used if empty. The idle timeout. `30m` will be used if empty. +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + #### interrupt_exist_connections Interrupt existing connections when the selected outbound has changed. Only inbound connections are affected by this setting, internal connections will always be interrupted. + diff --git a/docs/configuration/outbound/urltest.zh.md b/docs/configuration/outbound/urltest.zh.md index 4372298afc..8a53bc1ede 100644 --- a/docs/configuration/outbound/urltest.zh.md +++ b/docs/configuration/outbound/urltest.zh.md @@ -10,10 +10,17 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "url": "", "interval": "", "tolerance": 50, "idle_timeout": "", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -22,10 +29,20 @@ #### outbounds -==必填== - 用于测试的出站标签列表。 +#### providers + +用于测试的[订阅](/zh/configuration/provider)标签列表。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + #### url 用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 @@ -42,8 +59,12 @@ 空闲超时。默认使用 `30m`。 +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + #### interrupt_exist_connections 当选定的出站发生更改时,中断现有连接。 -仅入站连接受此设置影响,内部连接将始终被中断。 \ No newline at end of file +仅入站连接受此设置影响,内部连接将始终被中断。 diff --git a/docs/configuration/provider/index.md b/docs/configuration/provider/index.md new file mode 100644 index 0000000000..4ba7b18b85 --- /dev/null +++ b/docs/configuration/provider/index.md @@ -0,0 +1,130 @@ +# Provider + +### Structure + +List of subscription providers. + +=== "Local File" + + ```json + { + "providers": [ + { + "type": "local", + "tag": "provider", + "path": "provider.txt", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + } + } + ] + } + ``` + +=== "Remote File" + + ```json + { + "providers": [ + { + "type": "remote", + "tag": "provider", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + }, + "url": "", + "exclude": "", + "include": "", + "user_agent": "", + "download_detour": "", + "update_interval": "" + } + ] + } + ``` + +### Fields + +#### type + +==Required== + +Type of the provider. `local` or `remote`. + +#### tag + +==Required== + +Tag of the provider. + +The node `node_name` from `provider` will be tagged as `provider/node_name`. + +### Local or Remote Fields + +#### health_check + +Health check configuration. + +##### health_check.enabled + +Health check enabled. + +##### health_check.url + +Health check URL. + +##### health_check.interval + +Health check interval. The minimum value is `1m`, the default value is `10m`. + +##### health_check.timeout + +Health check timeout. the default value is `3s`. + +### Local Fields + +#### path + +==Required== + +!!! note "" + + Will be automatically reloaded if file modified since sing-box 1.10.0. + +Local file path. + +### Remote Fields + +#### url + +==Required== + +URL to the provider. + +#### exclude + +Exclude regular expression to filter nodes. + +#### include + +Include regular expression to filter nodes. + +#### user_agent + +User agent used to download the provider. + +#### download_detour + +The tag of the outbound used to download from the provider. + +Default outbound will be used if empty. + +#### update_interval + +Update interval. The minimum value is `1m`, the default value is `24h`. diff --git a/docs/configuration/provider/index.zh.md b/docs/configuration/provider/index.zh.md new file mode 100644 index 0000000000..c6b2ad4b7d --- /dev/null +++ b/docs/configuration/provider/index.zh.md @@ -0,0 +1,130 @@ +# 订阅 + +### 结构 + +订阅源列表。 + +=== "本地文件" + + ```json + { + "providers": [ + { + "type": "local", + "tag": "provider", + "path": "provider.txt", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + } + } + ] + } + ``` + +=== "远程文件" + + ```json + { + "providers": [ + { + "type": "remote", + "tag": "provider", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + }, + "url": "", + "exclude": "", + "include": "", + "user_agent": "", + "download_detour": "", + "update_interval": "" + } + ] + } + ``` + +### 字段 + +#### type + +==必填== + +订阅源的类型。`local` 或 `remote`。 + +#### tag + +==必填== + +订阅源的标签。 + +来自 `provider` 的节点 `node_name`,导入后的标签为 `provider/node_name`。 + +### 本地或远程字段 + +#### health_check + +健康检查配置。 + +##### health_check.enabled + +是否启用健康检查。 + +##### health_check.url + +健康检查的 URL。 + +##### health_check.interval + +健康检查的时间间隔。最小为 `1m`,默认为 `10m`。 + +##### health_check.timeout + +健康检查的超时时间。默认为 `3s`。 + +### 本地字段 + +#### path + +==必填== + +!!! note "" + + 自 sing-box 1.10.0 起, 文件更改将自动重新加载。 + +本地文件路径。 + +### 远程字段 + +#### url + +==必填== + +订阅源的 URL。 + +#### exclude + +排除节点的正则表达式。 + +#### include + +包含节点的正则表达式。 + +#### user_agent + +用于下载订阅内容的 User-Agent。 + +#### download_detour + +用于下载订阅内容的出站的标签。 + +如果为空,将使用默认出站。 + +#### update_interval + +更新订阅的时间间隔。最小为 `1m`,默认为 `24h`。 \ No newline at end of file diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index fbaf6bc805..c95081b061 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -394,3 +394,36 @@ func (c *CacheFile) SaveExternalUI(tag string, info *adapter.SavedBinary) error return bucket.Put([]byte(tag), setBinary) }) } + +func (c *CacheFile) LoadSubscription(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveSubscription(tag string, sub *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + setBinary, err := sub.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go index 352b28944e..f2487e49a9 100644 --- a/experimental/clashapi/provider.go +++ b/experimental/clashapi/provider.go @@ -4,48 +4,78 @@ import ( "context" "net/http" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json/badjson" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func proxyProviderRouter() http.Handler { +func proxyProviderRouter(server *Server) http.Handler { r := chi.NewRouter() - r.Get("/", getProviders) + r.Get("/", getProviders(server)) r.Route("/{name}", func(r chi.Router) { - r.Use(parseProviderName, findProviderByName) - r.Get("/", getProvider) + r.Use(parseProviderName, findProviderByName(server)) + r.Get("/", getProvider(server)) r.Put("/", updateProvider) r.Get("/healthcheck", healthCheckProvider) }) return r } -func getProviders(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{ - "providers": render.M{}, - }) +func getProviders(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + providerMap := make(render.M) + for _, provider := range server.provider.Providers() { + providerMap[provider.Tag()] = providerInfo(server, provider) + } + render.JSON(w, r, render.M{ + "providers": providerMap, + }) + } } -func getProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - render.JSON(w, r, provider)*/ - render.NoContent(w, r) +func getProvider(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + render.JSON(w, r, providerInfo(server, provider)) + } +} + +func providerInfo(server *Server, p adapter.Provider) *badjson.JSONObject { + var info badjson.JSONObject + proxies := make([]*badjson.JSONObject, 0) + for _, detour := range p.Outbounds() { + proxies = append(proxies, proxyInfo(server, detour)) + } + info.Put("type", "Proxy") // Proxy, Rule + info.Put("vehicleType", C.ProviderDisplayName(p.Type())) // HTTP, File, Compatible + info.Put("name", p.Tag()) + info.Put("proxies", proxies) + info.Put("updatedAt", p.UpdatedAt()) + if p, ok := p.(adapter.ProviderSubscriptionInfo); ok { + info.Put("subscriptionInfo", p.SubscriptionInfo()) + } + return &info } func updateProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - if err := provider.Update(); err != nil { - render.Status(r, http.StatusServiceUnavailable) - render.JSON(w, r, newError(err.Error())) - return - }*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + if provider, isUpdater := provider.(adapter.ProviderUpdater); isUpdater { + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + } + } render.NoContent(w, r) } func healthCheckProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - provider.HealthCheck()*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + provider.HealthCheck(r.Context()) render.NoContent(w, r) } @@ -57,18 +87,19 @@ func parseProviderName(next http.Handler) http.Handler { }) } -func findProviderByName(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - /*name := r.Context().Value(CtxKeyProviderName).(string) - providers := tunnel.ProxyProviders() - provider, exist := providers[name] - if !exist {*/ - render.Status(r, http.StatusNotFound) - render.JSON(w, r, ErrNotFound) - //return - //} +func findProviderByName(server *Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProviderName).(string) + provider, exist := server.provider.Get(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } - // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) - // next.ServeHTTP(w, r.WithContext(ctx)) - }) + ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index caf9ea3723..5d71001a5a 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -45,6 +45,7 @@ type Server struct { router adapter.Router dnsRouter adapter.DNSRouter outbound adapter.OutboundManager + provider adapter.ProviderManager endpoint adapter.EndpointManager logger log.Logger httpServer *http.Server @@ -82,6 +83,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op router: service.FromContext[adapter.Router](ctx), dnsRouter: service.FromContext[adapter.DNSRouter](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), + provider: service.FromContext[adapter.ProviderManager](ctx), endpoint: service.FromContext[adapter.EndpointManager](ctx), logger: logFactory.NewLogger("clash-api"), httpServer: &http.Server{ @@ -136,7 +138,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Mount("/proxies", proxyRouter(s, s.router)) r.Mount("/rules", ruleRouter(s.router, s.dnsRouter)) r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) - r.Mount("/providers/proxies", proxyProviderRouter()) + r.Mount("/providers/proxies", proxyProviderRouter(s)) r.Mount("/providers/rules", ruleProviderRouter()) r.Mount("/script", scriptRouter()) r.Mount("/profile", profileRouter()) diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 54369bf770..37707bdfd9 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -33,7 +33,7 @@ func baseContext(platformInterface PlatformInterface) context.Context { } ctx := context.Background() ctx = filemanager.WithDefault(ctx, sWorkingPath, sTempPath, sUserID, sGroupID) - return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry()) + return box.Context(ctx, include.InboundRegistry(), include.ProviderRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry()) } func parseConfig(ctx context.Context, configContent string) (option.Options, error) { diff --git a/go.mod b/go.mod index 3c5cebdd48..dc8c5c4891 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 ) @@ -163,6 +164,5 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) diff --git a/include/registry.go b/include/registry.go index e0511c1454..4db653949f 100644 --- a/include/registry.go +++ b/include/registry.go @@ -8,6 +8,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -34,13 +35,15 @@ import ( "github.com/sagernet/sing-box/protocol/tun" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" + providerLocal "github.com/sagernet/sing-box/provider/local" + "github.com/sagernet/sing-box/provider/remote" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" ) func Context(ctx context.Context) context.Context { - return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) + return box.Context(ctx, InboundRegistry(), ProviderRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) } func InboundRegistry() *inbound.Registry { @@ -69,6 +72,16 @@ func InboundRegistry() *inbound.Registry { return registry } +func ProviderRegistry() *provider.Registry { + registry := provider.NewRegistry() + + providerLocal.RegisterProviderInline(registry) + providerLocal.RegisterProviderLocal(registry) + remote.RegisterProvider(registry) + + return registry +} + func OutboundRegistry() *outbound.Registry { registry := outbound.NewRegistry() diff --git a/mkdocs.yml b/mkdocs.yml index 8a12949ada..f75a656adf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -176,6 +176,8 @@ nav: - DNS: configuration/outbound/dns.md - Selector: configuration/outbound/selector.md - URLTest: configuration/outbound/urltest.md + - Provider: + - configuration/provider/index.md - Service: - configuration/service/index.md - DERP: configuration/service/derp.md @@ -278,6 +280,7 @@ plugins: Endpoint: 端点 Inbound: 入站 Outbound: 出站 + Provider: 提供者 Manual: 手册 reconfigure_material: true diff --git a/option/group.go b/option/group.go index 02b3a5ecb9..6aa6a1d34c 100644 --- a/option/group.go +++ b/option/group.go @@ -3,16 +3,24 @@ package option import "github.com/sagernet/sing/common/json/badoption" type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + GroupCommonOption + Default string `json:"default,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` + GroupCommonOption URL string `json:"url,omitempty"` Interval badoption.Duration `json:"interval,omitempty"` Tolerance uint16 `json:"tolerance,omitempty"` IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } + +type GroupCommonOption struct { + Outbounds []string `json:"outbounds"` + Providers []string `json:"providers"` + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + UseAllProviders bool `json:"use_all_providers,omitempty"` +} diff --git a/option/options.go b/option/options.go index 8bebd48fc6..fcca94c35a 100644 --- a/option/options.go +++ b/option/options.go @@ -19,6 +19,7 @@ type _Options struct { Endpoints []Endpoint `json:"endpoints,omitempty"` Inbounds []Inbound `json:"inbounds,omitempty"` Outbounds []Outbound `json:"outbounds,omitempty"` + Providers []Provider `json:"providers,omitempty"` Route *RouteOptions `json:"route,omitempty"` Services []Service `json:"services,omitempty"` Experimental *ExperimentalOptions `json:"experimental,omitempty"` diff --git a/option/provider.go b/option/provider.go new file mode 100644 index 0000000000..656036e4b9 --- /dev/null +++ b/option/provider.go @@ -0,0 +1,75 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/service" +) + +type ProviderOptionsRegistry interface { + CreateOptions(providerType string) (any, bool) +} +type _Provider struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Provider _Provider + +func (h *Provider) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Provider)(h), h.Options) +} + +func (h *Provider) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Provider)(h)) + if err != nil { + return err + } + registry := service.FromContext[ProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing provider options registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown provider type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Provider)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} + +type ProviderLocalOptions struct { + Path string `json:"path"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderRemoteOptions struct { + URL string `json:"url"` + UserAgent string `json:"user_agent,omitempty"` + DownloadDetour string `json:"download_detour,omitempty"` + UpdateInterval badoption.Duration `json:"update_interval,omitempty"` + + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderInlineOptions struct { + Outbounds []Outbound `json:"outbounds,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderHealthCheckOptions struct { + Enabled bool `json:"enabled,omitempty"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` +} diff --git a/protocol/group/selector.go b/protocol/group/selector.go index f3f7377b61..ef7d6a5280 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "time" "github.com/sagernet/sing-box/adapter" @@ -42,11 +43,20 @@ type Selector struct { selected common.TypedValue[adapter.Outbound] interruptGroup *interrupt.Group interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (adapter.Outbound, error) { outbound := &Selector{ - Adapter: outbound.NewAdapter(C.TypeSelector, tag, nil, options.Outbounds), + Adapter: outbound.NewAdapter(C.TypeSelector, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), ctx: ctx, outbound: service.FromContext[adapter.OutboundManager](ctx), connection: service.FromContext[adapter.ConnectionManager](ctx), @@ -56,9 +66,15 @@ func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextL outbounds: make(map[string]adapter.Outbound), interruptGroup: interrupt.NewGroup(), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } @@ -72,6 +88,28 @@ func (s *Selector) Network() []string { } func (s *Selector) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) if !loaded { @@ -79,31 +117,16 @@ func (s *Selector) Start() error { } s.outbounds[tag] = detour } - - if s.Tag() != "" { - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - selected := cacheFile.LoadSelected(s.Tag()) - if selected != "" { - detour, loaded := s.outbounds[selected] - if loaded { - s.selected.Store(detour) - return nil - } - } - } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + s.outbounds[detour.Tag()] = detour } - - if s.defaultTag != "" { - detour, loaded := s.outbounds[s.defaultTag] - if !loaded { - return E.New("default outbound not found: ", s.defaultTag) - } - s.selected.Store(detour) - return nil + outbound, err := s.outboundSelect() + if err != nil { + return err } - - s.selected.Store(s.outbounds[s.tags[0]]) + s.selected.Store(outbound) return nil } @@ -145,7 +168,7 @@ func (s *Selector) DialContext(ctx context.Context, network string, destination if err != nil { return nil, err } - return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -153,7 +176,7 @@ func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (n if err != nil { return nil, err } - return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { @@ -190,3 +213,77 @@ func RealTag(detour adapter.Outbound) string { } return detour.Tag() } + +func (s *Selector) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New(s.Tag(), ": ", "outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outboundByTag = make(map[string]adapter.Outbound) + ) + for _, tag := range tags { + outboundByTag[tag] = s.outbounds[tag] + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + outboundByTag[tag] = detour + } + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + s.tags, s.outbounds = tags, outboundByTag + detour, _ := s.outboundSelect() + if s.selected.Swap(detour) != detour { + s.interruptGroup.Interrupt(s.interruptExternalConnections) + } + return nil +} + +func (s *Selector) outboundSelect() (adapter.Outbound, error) { + if s.Tag() != "" { + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + selected := cacheFile.LoadSelected(s.Tag()) + if selected != "" { + detour, loaded := s.outbounds[selected] + if loaded { + return detour, nil + } + } + } + } + + if s.defaultTag != "" { + detour, loaded := s.outbounds[s.defaultTag] + if !loaded { + return nil, E.New("default outbound not found: ", s.defaultTag) + } + return detour, nil + } + + return s.outbounds[s.tags[0]], nil +} diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 26967279db..682dc74dbf 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "sync" "sync/atomic" "time" @@ -45,6 +46,16 @@ type URLTest struct { idleTimeout time.Duration group *URLTestGroup interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + cancel context.CancelFunc + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { @@ -61,14 +72,42 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo tolerance: options.Tolerance, idleTimeout: time.Duration(options.IdleTimeout), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } func (s *URLTest) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + outbounds := make([]adapter.Outbound, 0, len(s.tags)) for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) @@ -77,6 +116,11 @@ func (s *URLTest) Start() error { } outbounds = append(outbounds, detour) } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + outbounds = append(outbounds, detour) + } group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) if err != nil { return err @@ -136,7 +180,7 @@ func (s *URLTest) DialContext(ctx context.Context, network string, destination M } conn, err := outbound.DialContext(ctx, network, destination) if err == nil { - return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -154,7 +198,7 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne } conn, err := outbound.ListenPacket(ctx, destination) if err == nil { - return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -186,6 +230,63 @@ func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, rout return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) } +func (s *URLTest) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New("outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outbounds []adapter.Outbound + ) + for _, tag := range tags { + detour, _ := s.outbound.Outbound(tag) + outbounds = append(outbounds, detour) + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + } + outbounds = append(outbounds, cache...) + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + s.tags, s.group.outbounds = tags, outbounds + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) + return nil +} + type URLTestGroup struct { ctx context.Context router adapter.Router @@ -405,7 +506,11 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint }) } b.Wait() - g.performUpdateCheck() + select { + case <-ctx.Done(): + default: + g.performUpdateCheck() + } return result, nil } diff --git a/provider/local/lcoal.go b/provider/local/lcoal.go new file mode 100644 index 0000000000..63a4fdad16 --- /dev/null +++ b/provider/local/lcoal.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/provider/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func RegisterProviderLocal(registry *provider.Registry) { + provider.Register[option.ProviderLocalOptions](registry, C.ProviderTypeLocal, NewProviderLocal) +} + +func RegisterProviderInline(registry *provider.Registry) { + provider.Register[option.ProviderInlineOptions](registry, C.ProviderTypeInline, NewProviderInline) +} + +var _ adapter.Provider = (*ProviderLocal)(nil) + +type ProviderLocal struct { + provider.Adapter + ctx context.Context + logger log.ContextLogger + provider adapter.ProviderManager + path string + lastOutOpts []option.Outbound + lastUpdated time.Time + watcher *fswatch.Watcher +} + +func NewProviderInline(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderInlineOptions) (adapter.Provider, error) { + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/inline", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeInline, options.HealthCheck), + ctx: ctx, + logger: logger, + } + provider.UpdateOutbounds(nil, options.Outbounds) + return provider, nil +} + +func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderLocalOptions) (adapter.Provider, error) { + if options.Path == "" { + return nil, E.New("provider path is required") + } + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/local", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeLocal, options.HealthCheck), + ctx: ctx, + logger: logger, + provider: service.FromContext[adapter.ProviderManager](ctx), + } + filePath := filemanager.BasePath(ctx, options.Path) + provider.path, _ = filepath.Abs(filePath) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{filePath}, + Callback: func(path string) { + uErr := provider.reloadFile(path) + if uErr != nil { + logger.Error(E.Cause(uErr, "reload provider ", tag)) + } + provider.UpdateGroups() + }, + }) + if err != nil { + return nil, err + } + provider.watcher = watcher + return provider, nil +} + +func (s *ProviderLocal) Start() error { + err := s.reloadFile(s.path) + if err != nil { + return err + } + s.UpdateGroups() + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + s.logger.Error(E.Cause(err, "watch provider file")) + } + } + return s.Adapter.Start() +} + +func (s *ProviderLocal) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderLocal) reloadFile(path string) error { + if fileInfo, err := os.Stat(path); err == nil { + s.lastUpdated = fileInfo.ModTime() + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content)) + if err != nil { + return err + } + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func (s *ProviderLocal) Close() error { + return common.Close(&s.Adapter, common.PtrOrNil(s.watcher)) +} diff --git a/provider/parser/clash.go b/provider/parser/clash.go new file mode 100644 index 0000000000..ac999b7c87 --- /dev/null +++ b/provider/parser/clash.go @@ -0,0 +1,843 @@ +package parser + +import ( + "context" + "encoding/base64" + "strconv" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" + + "gopkg.in/yaml.v3" +) + +type ClashConfig struct { + Proxies []ClashProxy `yaml:"proxies"` +} + +type _ClashProxy struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Options Proxy `yaml:"-"` + + SingType string `yaml:"-"` +} +type ClashProxy _ClashProxy + +type Proxy interface { + Build() any +} + +func (c *ClashProxy) UnmarshalYAML(value *yaml.Node) error { + err := value.Decode((*_ClashProxy)(c)) + if err != nil { + return err + } + var options Proxy + switch c.Type { + case "ss": + c.SingType = C.TypeShadowsocks + options = &ShadowSocksOption{} + case "tuic": + c.SingType = C.TypeTUIC + options = &TuicOption{} + case "vmess": + c.SingType = C.TypeVMess + options = &VmessOption{} + case "vless": + c.SingType = C.TypeVLESS + options = &VlessOption{} + case "socks5": + c.SingType = C.TypeSOCKS + options = &Socks5Option{} + case "http": + c.SingType = C.TypeHTTP + options = &HttpOption{} + case "trojan": + c.SingType = C.TypeTrojan + options = &TrojanOption{} + case "hysteria": + c.SingType = C.TypeHysteria + options = &HysteriaOption{} + case "hysteria2": + c.SingType = C.TypeHysteria2 + options = &Hysteria2Option{} + case "ssh": + c.SingType = C.TypeSSH + options = &SSHOption{} + case "anytls": + c.SingType = C.TypeAnyTLS + options = &AnyTLSOption{} + default: + return nil + } + err = value.Decode(options) + if err != nil { + return err + } + c.Options = options + return nil +} + +func (c *ClashProxy) Build() option.Outbound { + outbound := option.Outbound{ + Tag: c.Name, + Type: c.SingType, + } + if c.Options != nil { + outbound.Options = c.Options.Build() + } + return outbound +} + +func ParseClashSubscription(_ context.Context, content string) ([]option.Outbound, error) { + config := &ClashConfig{} + err := yaml.Unmarshal([]byte(content), &config) + if err != nil { + return nil, E.Cause(err, "parse clash config") + } + outbounds := common.FilterIsInstance(config.Proxies, func(proxy ClashProxy) (option.Outbound, bool) { + if proxy.SingType == "" { + return option.Outbound{}, false + } + return proxy.Build(), true + }) + return outbounds, nil +} + +type ShadowSocksOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + Password string `yaml:"password"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Plugin string `yaml:"plugin,omitempty"` + PluginOpts map[string]any `yaml:"plugin-opts,omitempty"` + UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"` + UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (s *ShadowSocksOption) Build() any { + return &option.ShadowsocksOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Password: s.Password, + Method: clashShadowsocksCipher(s.Cipher), + Plugin: clashPluginName(s.Plugin), + PluginOptions: clashPluginOptions(s.Plugin, s.PluginOpts), + Network: clashNetworks(s.UDP), + UDPOverTCP: &option.UDPOverTCPOptions{ + Enabled: s.UDPOverTCP, + Version: uint8(s.UDPOverTCPVersion), + }, + Multiplex: s.MuxOpts.Build(), + } +} + +type TuicOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid,omitempty"` + Password string `yaml:"password,omitempty"` + Ip string `yaml:"ip,omitempty"` + HeartbeatInterval int `yaml:"heartbeat-interval,omitempty"` + DisableSni bool `yaml:"disable-sni,omitempty"` + ReduceRtt bool `yaml:"reduce-rtt,omitempty"` + UdpRelayMode string `yaml:"udp-relay-mode,omitempty"` + CongestionController string `yaml:"congestion-controller,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + UDPOverStream bool `yaml:"udp-over-stream,omitempty"` +} + +func (t *TuicOption) Build() any { + t.TLS = true + t.TFO = t.FastOpen + options := &option.TUICOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + UUID: t.UUID, + Password: t.Password, + CongestionControl: t.CongestionController, + UDPRelayMode: t.UdpRelayMode, + UDPOverStream: t.UDPOverStream, + ZeroRTTHandshake: t.ReduceRtt, + Heartbeat: badoption.Duration(t.HeartbeatInterval), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + } + if t.Ip != "" { + options.Server = t.Ip + } + if t.DisableSni { + options.TLS.DisableSNI = true + } + return options +} + +type VmessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + AlterID int `yaml:"alterId"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + GlobalPadding bool `yaml:"global-padding,omitempty"` + AuthenticatedLength bool `yaml:"authenticated-length,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VmessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.XUDP { + v.PacketEncoding = "xudp" + } else if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VMessOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Security: v.Cipher, + AlterId: v.AlterID, + GlobalPadding: v.GlobalPadding, + AuthenticatedLength: v.AuthenticatedLength, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + PacketEncoding: v.PacketEncoding, + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + } +} + +type VlessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + Flow string `yaml:"flow,omitempty"` + UDP bool `yaml:"udp,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VlessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } else { + v.PacketEncoding = "xudp" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VLESSOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Flow: v.Flow, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + PacketEncoding: &v.PacketEncoding, + } +} + +type Socks5Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + UDP bool `yaml:"udp,omitempty"` +} + +func (s *Socks5Option) Build() any { + return &option.SOCKSOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Username: s.UserName, + Password: s.Password, + Network: clashNetworks(s.UDP), + } +} + +type HttpOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` +} + +func (h *HttpOption) Build() any { + return &option.HTTPOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + Username: h.UserName, + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, h.TLSOptions), + Headers: clashHeaders(h.Headers), + } +} + +type TrojanOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (t *TrojanOption) Build() any { + t.TLS = true + return &option.TrojanOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + Password: t.Password, + Network: clashNetworks(t.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + Multiplex: t.MuxOpts.Build(), + Transport: clashTransport(t.Network, HTTPOptions{}, HTTP2Options{}, t.GrpcOpts, t.WSOpts), + } +} + +type HysteriaOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + Up string `yaml:"up"` + UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash + Down string `yaml:"down"` + DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash + Auth string `yaml:"auth,omitempty"` + AuthString string `yaml:"auth-str,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"` + ReceiveWindow int `yaml:"recv-window,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` +} + +func (h *HysteriaOption) Build() any { + h.TLS = true + h.TFO = h.FastOpen + return &option.HysteriaOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + Up: clashSpeedToNetworkBytes(h.Up), + UpMbps: h.UpSpeed, + Down: clashSpeedToNetworkBytes(h.Down), + DownMbps: h.DownSpeed, + Obfs: h.Obfs, + Auth: []byte(h.Auth), + AuthString: h.AuthString, + ReceiveWindowConn: uint64(h.ReceiveWindowConn), + ReceiveWindow: uint64(h.ReceiveWindow), + DisableMTUDiscovery: h.DisableMTUDiscovery, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} + +type Hysteria2Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` + Password string `yaml:"password,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ObfsPassword string `yaml:"obfs-password,omitempty"` +} + +func (h *Hysteria2Option) Build() any { + h.TLS = true + return &option.Hysteria2OutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + UpMbps: clashSpeedToIntMbps(h.Up), + DownMbps: clashSpeedToIntMbps(h.Down), + Obfs: clashHysteria2Obfs(h.Obfs, h.ObfsPassword), + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} + +type SSHOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username"` + Password string `yaml:"password,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + PrivateKeyPassphrase string `yaml:"private-key-passphrase,omitempty"` + HostKey []string `yaml:"host-key,omitempty"` + HostKeyAlgorithms []string `yaml:"host-key-algorithms,omitempty"` +} + +func (s *SSHOption) Build() any { + options := &option.SSHOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + User: s.UserName, + Password: s.Password, + PrivateKeyPassphrase: s.PrivateKeyPassphrase, + HostKey: s.HostKey, + HostKeyAlgorithms: s.HostKeyAlgorithms, + } + if strings.Contains(s.PrivateKey, "PRIVATE KEY") { + options.PrivateKey = trimStringArray(strings.Split(s.PrivateKey, "\n")) + } else { + options.PrivateKeyPath = s.PrivateKey + } + return options +} + +type AnyTLSOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"` + IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"` + MinIdleSession int `yaml:"min-idle-session,omitempty"` +} + +func (a *AnyTLSOption) Build() any { + a.TLS = true + return &option.AnyTLSOutboundOptions{ + DialerOptions: a.DialerOptions.Build(), + ServerOptions: a.ServerOptions.Build(), + OutboundTLSOptionsContainer: clashTLSOptions(a.Server, &a.TLSOptions), + Password: a.Password, + IdleSessionCheckInterval: badoption.Duration(a.IdleSessionCheckInterval), + IdleSessionTimeout: badoption.Duration(a.IdleSessionTimeout), + MinIdleSession: a.MinIdleSession, + } +} + +type HTTPOptions struct { + Method string `yaml:"method,omitempty"` + Path []string `yaml:"path,omitempty"` + Headers badoption.HTTPHeader `yaml:"headers,omitempty"` +} + +type HTTP2Options struct { + Host []string `yaml:"host,omitempty"` + Path string `yaml:"path,omitempty"` +} + +type GrpcOptions struct { + GrpcServiceName string `yaml:"grpc-service-name,omitempty"` +} + +type WSOptions struct { + Path string `yaml:"path,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + MaxEarlyData int `yaml:"max-early-data,omitempty"` + EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"` + V2rayHttpUpgrade bool `yaml:"v2ray-http-upgrade,omitempty"` +} + +type MuxOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Protocol string `yaml:"protocol,omitempty"` + MaxConnections int `yaml:"max-connections,omitempty"` + MinStreams int `yaml:"min-streams,omitempty"` + MaxStreams int `yaml:"max-streams,omitempty"` + Padding bool `yaml:"padding,omitempty"` + BrutalOpts *BrutalOptions `yaml:"brutal-opts,omitempty"` +} + +func (s *MuxOptions) Build() *option.OutboundMultiplexOptions { + if s == nil { + return nil + } + return &option.OutboundMultiplexOptions{ + Enabled: s.Enabled, + Protocol: s.Protocol, + MaxConnections: s.MaxConnections, + MinStreams: s.MinStreams, + MaxStreams: s.MaxStreams, + Padding: s.Padding, + Brutal: s.BrutalOpts.Build(), + } +} + +type BrutalOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` +} + +func (b *BrutalOptions) Build() *option.BrutalOptions { + if b == nil { + return nil + } + return &option.BrutalOptions{ + Enabled: b.Enabled, + UpMbps: clashSpeedToIntMbps(b.Up), + DownMbps: clashSpeedToIntMbps(b.Down), + } +} + +type RealityOptions struct { + PublicKey string `yaml:"public-key"` + ShortID string `yaml:"short-id"` +} + +func (r *RealityOptions) Build() *option.OutboundRealityOptions { + if r == nil { + return nil + } + return &option.OutboundRealityOptions{ + Enabled: true, + PublicKey: r.PublicKey, + ShortID: r.ShortID, + } +} + +type ECHOptions struct { + Enable bool `yaml:"enable,omitempty"` + Config string `yaml:"config,omitempty"` +} + +func (e *ECHOptions) Build() *option.OutboundECHOptions { + if e == nil { + return nil + } + list, err := base64.StdEncoding.DecodeString(e.Config) + if err != nil { + return nil + } + return &option.OutboundECHOptions{ + Enabled: e.Enable, + Config: trimStringArray(strings.Split(string(list), "\n")), + } +} + +type TLSOptions struct { + TLS bool `yaml:"tls,omitempty"` + SNI string `yaml:"sni,omitempty"` + SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` + ALPN []string `yaml:"alpn,omitempty"` + ClientFingerprint string `yaml:"client-fingerprint,omitempty"` + CustomCA string `yaml:"ca,omitempty"` + CustomCAString string `yaml:"ca-str,omitempty"` + Certificate string `yaml:"certificate,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + ECHOpts *ECHOptions `yaml:"ech-opts,omitempty"` + RealityOpts *RealityOptions `yaml:"reality-opts,omitempty"` +} + +func (t *TLSOptions) Build() *option.OutboundTLSOptions { + if t == nil { + return nil + } + options := &option.OutboundTLSOptions{ + Enabled: t.TLS, + ServerName: t.SNI, + Insecure: t.SkipCertVerify, + ALPN: t.ALPN, + UTLS: clashClientFingerprint(t.ClientFingerprint), + Certificate: trimStringArray(strings.Split(t.CustomCAString, "\n")), + CertificatePath: t.CustomCA, + ECH: t.ECHOpts.Build(), + Reality: t.RealityOpts.Build(), + } + if strings.HasPrefix(t.Certificate, "-----BEGIN ") { + options.ClientCertificate = trimStringArray(strings.Split(t.Certificate, "\n")) + } else { + options.ClientCertificatePath = t.Certificate + } + if strings.HasPrefix(t.PrivateKey, "-----BEGIN ") { + options.ClientKey = trimStringArray(strings.Split(t.PrivateKey, "\n")) + } else { + options.ClientKeyPath = t.PrivateKey + } + return options +} + +type DialerOptions struct { + TFO bool `yaml:"tfo,omitempty"` + MPTCP bool `yaml:"mptcp,omitempty"` + Interface string `yaml:"interface-name,omitempty"` + RoutingMark int `yaml:"routing-mark,omitempty"` + DialerProxy string `yaml:"dialer-proxy,omitempty"` +} + +func (b *DialerOptions) Build() option.DialerOptions { + return option.DialerOptions{ + Detour: b.DialerProxy, + BindInterface: b.Interface, + TCPFastOpen: b.TFO, + TCPMultiPath: b.MPTCP, + RoutingMark: option.FwMark(b.RoutingMark), + } +} + +type ServerOptions struct { + Server string `yaml:"server"` + Port int `yaml:"port"` +} + +func (s *ServerOptions) Build() option.ServerOptions { + return option.ServerOptions{ + Server: s.Server, + ServerPort: uint16(s.Port), + } +} + +type shadowsocksPluginOptionsBuilder map[string]any + +func (o shadowsocksPluginOptionsBuilder) Build() string { + var opts []string + for key, value := range o { + if value == nil { + continue + } + opts = append(opts, F.ToString(key, "=", value)) + } + return strings.Join(opts, ";") +} + +func clashClientFingerprint(clientFingerprint string) *option.OutboundUTLSOptions { + if clientFingerprint == "" { + return nil + } + return &option.OutboundUTLSOptions{ + Enabled: true, + Fingerprint: clientFingerprint, + } +} + +func clashHeaders(headers map[string]string) map[string]badoption.Listable[string] { + if headers == nil { + return nil + } + result := make(map[string]badoption.Listable[string]) + for key, value := range headers { + result[key] = []string{value} + } + return result +} + +func clashHysteria2Obfs(obfs string, password string) *option.Hysteria2Obfs { + if obfs == "" { + return nil + } + return &option.Hysteria2Obfs{ + Type: obfs, + Password: password, + } +} + +func clashNetworks(udpEnabled bool) option.NetworkList { + if !udpEnabled { + return N.NetworkTCP + } + return "" +} + +func clashPluginName(plugin string) string { + switch plugin { + case "obfs": + return "obfs-local" + } + return plugin +} + +func clashPluginOptions(plugin string, opts map[string]any) string { + options := make(shadowsocksPluginOptionsBuilder) + switch plugin { + case "obfs": + options["obfs"] = opts["mode"] + options["obfs-host"] = opts["host"] + case "v2ray-plugin": + options["mode"] = opts["mode"] + options["tls"] = opts["tls"] + options["host"] = opts["host"] + options["path"] = opts["path"] + } + return options.Build() +} + +func clashPorts(ports string) badoption.Listable[string] { + if ports == "" { + return nil + } + serverPorts := badoption.Listable[string]{} + ports = strings.ReplaceAll(ports, "/", ",") + for _, port := range strings.Split(ports, ",") { + if port == "" { + continue + } + port = strings.Replace(port, "-", ":", 1) + serverPorts = append(serverPorts, port) + } + return serverPorts +} + +func clashShadowsocksCipher(cipher string) string { + switch cipher { + case "dummy": + return "none" + } + return cipher +} + +func clashStringList(list []string) string { + if len(list) > 0 { + return list[0] + } + return "" +} + +func clashSpeedToIntMbps(speed string) int { + if speed == "" { + return 0 + } + if num, err := strconv.Atoi(speed); err == nil { + return num + } + networkBytes := byteformats.NetworkBytesCompat{} + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return 0 + } + return int(networkBytes.Value() / byteformats.MByte * 8) +} + +func clashSpeedToNetworkBytes(speed string) *byteformats.NetworkBytesCompat { + if speed == "" { + return nil + } + networkBytes := &byteformats.NetworkBytesCompat{} + if num, err := strconv.Atoi(speed); err == nil { + speed = F.ToString(num, "Mbps") + } + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return nil + } + return networkBytes +} + +func clashTransport(network string, httpOpts HTTPOptions, h2Opts HTTP2Options, grpcOpts GrpcOptions, wsOpts WSOptions) *option.V2RayTransportOptions { + switch network { + case "http": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Method: httpOpts.Method, + Path: clashStringList(httpOpts.Path), + Headers: httpOpts.Headers, + }, + } + case "h2": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Path: h2Opts.Path, + Host: h2Opts.Host, + }, + } + case "grpc": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: grpcOpts.GrpcServiceName, + }, + } + case "ws": + headers := clashHeaders(wsOpts.Headers) + if wsOpts.V2rayHttpUpgrade { + var host string + if headers != nil && headers["Host"] != nil { + host = headers["Host"][0] + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTPUpgrade, + HTTPUpgradeOptions: option.V2RayHTTPUpgradeOptions{ + Host: host, + Path: wsOpts.Path, + Headers: headers, + }, + } + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Path: wsOpts.Path, + Headers: headers, + MaxEarlyData: uint32(wsOpts.MaxEarlyData), + EarlyDataHeaderName: wsOpts.EarlyDataHeaderName, + }, + } + default: + return nil + } +} + +func clashTLSOptions(server string, tlsOptions *TLSOptions) option.OutboundTLSOptionsContainer { + if tlsOptions != nil && tlsOptions.SNI == "" { + tlsOptions.SNI = server + } + return option.OutboundTLSOptionsContainer{ + TLS: tlsOptions.Build(), + } +} + +func trimStringArray(array []string) []string { + return common.Filter(array, func(it string) bool { + return strings.TrimSpace(it) != "" + }) +} diff --git a/provider/parser/link.go b/provider/parser/link.go new file mode 100644 index 0000000000..a61b9df551 --- /dev/null +++ b/provider/parser/link.go @@ -0,0 +1,662 @@ +package parser + +import ( + "net/url" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +func ParseSubscriptionLink(link string) (option.Outbound, error) { + reg := regexp.MustCompile(`^(.*?)(://)(.*?)([@?#].*)?$`) + result := reg.FindStringSubmatch(link) + if result == nil { + return option.Outbound{}, E.New("invalid link") + } + + scheme := result[1] + switch scheme { + case "tuic": + return parseTuicLink(link) + case "trojan": + return parseTrojanLink(link) + case "vless": + return parseVLESSLink(link) + case "hysteria": + return parseHysteriaLink(link) + case "hy2", "hysteria2": + return parseHysteria2Link(link) + } + result[3], _ = DecodeBase64URLSafe(result[3]) + link = strings.Join(result[1:], "") + switch scheme { + case "ss": + return parseShadowsocksLink(link) + case "vmess": + return parseVMessLink(link) + default: + return option.Outbound{}, E.New("unsupported scheme: ", scheme) + } +} + +func StringToType[T any](str string) T { + var value T + v := reflect.ValueOf(&value).Elem() + switch any(value).(type) { + case badoption.Duration: + d, err := time.ParseDuration(str) + if err != nil { + v.SetInt(StringToType[int64](str)) + } else { + v.Set(reflect.ValueOf(d)) + } + return value + case badoption.HTTPHeader: + headers := badoption.HTTPHeader{} + reg := regexp.MustCompile(`^[ \t]*?(\S+?):[ \t]*?(\S+?)[ \t]*?$`) + for _, header := range strings.Split(str, "\n") { + result := reg.FindStringSubmatch(header) + if result != nil { + key := result[1] + headers[key] = strings.Split(result[2], ",") + } + } + v.Set(reflect.ValueOf(headers)) + return value + } + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, _ := strconv.ParseInt(str, 10, 64) + v.SetInt(i) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + i, _ := strconv.ParseUint(str, 10, 64) + v.SetUint(i) + case reflect.Float32, reflect.Float64: + f, _ := strconv.ParseFloat(str, 64) + v.SetFloat(f) + case reflect.Bool: + b, _ := strconv.ParseBool(str) + v.SetBool(b) + default: + panic("unsupported type") + } + return value +} + +func shadowsocksPluginName(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[:index] + } + return plugin +} + +func shadowsocksPluginOptions(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[index+1:] + } + return "" +} + +func v2rayTransportWsPath(WebsocketOptions *option.V2RayWebsocketOptions, path string) { + reg := regexp.MustCompile(`^(.*?)(?:\?ed=(\d*))?$`) + result := reg.FindStringSubmatch(path) + WebsocketOptions.Path = result[1] + if result[2] != "" { + WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol" + WebsocketOptions.MaxEarlyData = StringToType[uint32](result[2]) + } +} + +func v2rayTransportWs(host string, path string) option.V2RayWebsocketOptions { + var WebsocketOptions option.V2RayWebsocketOptions + if host != "" { + WebsocketOptions.Headers = StringToType[badoption.HTTPHeader](F.ToString("Host: ", host)) + } + if path != "" { + v2rayTransportWsPath(&WebsocketOptions, path) + } + return WebsocketOptions +} + +func parseShadowsocksLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing user info") + } + var options option.ShadowsocksOutboundOptions + options.ServerOptions.Server = linkURL.Hostname() + options.ServerOptions.ServerPort = StringToType[uint16](linkURL.Port()) + password, _ := linkURL.User.Password() + if password == "" { + return option.Outbound{}, E.New("bad user info") + } + options.Method = linkURL.User.Username() + options.Password = password + plugin := linkURL.Query().Get("plugin") + options.Plugin = shadowsocksPluginName(plugin) + options.PluginOptions = shadowsocksPluginOptions(plugin) + + outbound := option.Outbound{ + Type: C.TypeShadowsocks, + Tag: linkURL.Fragment, + } + outbound.Options = &options + return outbound, nil +} + +func parseTuicLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.TUICOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Password, _ = linkURL.User.Password() + options.ServerOptions.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerOptions.ServerPort = StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "congestion_control": + if value != "cubic" { + options.CongestionControl = value + } + case "udp_relay_mode": + options.UDPRelayMode = value + case "udp_over_stream": + if value == "true" || value == "1" { + options.UDPOverStream = true + } + case "zero_rtt_handshake", "reduce_rtt": + if value == "true" || value == "1" { + options.ZeroRTTHandshake = true + } + case "heartbeat_interval": + options.Heartbeat = StringToType[badoption.Duration](value) + case "sni": + TLSOptions.ServerName = value + case "insecure", "skip-cert-verify", "allow_insecure": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "disable_sni": + if value == "1" || value == "true" { + TLSOptions.DisableSNI = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + } + } + if options.UDPOverStream { + options.UDPRelayMode = "" + } + outbound := option.Outbound{ + Type: C.TypeTUIC, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseVMessLink(link string) (option.Outbound, error) { + var proxy map[string]string + reg := regexp.MustCompile(`(\"[^:,]+?\"[ \t]*:[ \t]*)(\d+|true|false)`) + s := reg.ReplaceAllString(link, `$1"$2"`) + err := json.Unmarshal([]byte(s[8:]), &proxy) + if err != nil { + proxy = make(map[string]string) + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + proxy["id"] = linkURL.User.Username() + proxy["add"] = linkURL.Hostname() + proxy["port"] = linkURL.Port() + proxy["ps"] = linkURL.Fragment + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "type": + if value == "http" { + proxy["net"] = "tcp" + proxy["type"] = "http" + } + case "encryption": + proxy["scy"] = value + case "alterId": + proxy["aid"] = value + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + } + outbound := option.Outbound{ + Type: C.TypeVMess, + } + options := option.VMessOutboundOptions{ + Security: "auto", + } + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + for key, value := range proxy { + switch key { + case "ps": + outbound.Tag = value + case "add": + options.Server = value + TLSOptions.ServerName = value + case "port": + options.ServerPort = StringToType[uint16](value) + case "id": + options.UUID = value + case "scy": + options.Security = value + case "aid": + options.AlterId, _ = strconv.Atoi(value) + case "packet_encoding": + options.PacketEncoding = value + case "xudp": + if value == "1" || value == "true" { + options.PacketEncoding = "xudp" + } + case "tls": + if value == "1" || value == "true" || value == "tls" { + TLSOptions.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "net": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: badoption.HTTPHeader{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "h2": + Transport.Type = C.V2RayTransportTypeHTTP + TLSOptions.Enabled = true + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "tcp": + if tType, exists := proxy["type"]; exists { + if tType != "http" { + continue + } + Transport.Type = C.V2RayTransportTypeHTTP + if method, exists := proxy["method"]; exists { + Transport.HTTPOptions.Method = method + } + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + if headers, exists := proxy["headers"]; exists { + Transport.HTTPOptions.Headers = StringToType[badoption.HTTPHeader](headers) + } + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if host, exists := proxy["host"]; exists && host != "" { + Transport.GRPCOptions.ServiceName = host + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} + +func parseVLESSLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.VLESSOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + for key, value := range proxy { + switch key { + case "type": + Transport := option.V2RayTransportOptions{ + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: badoption.HTTPHeader{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "http": + Transport.Type = C.V2RayTransportTypeHTTP + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = strings.Split(host, ",") + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["serviceName"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "security": + if value == "tls" { + TLSOptions.Enabled = true + } else if value == "reality" { + TLSOptions.Enabled = true + TLSOptions.Reality.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "flow": + if value == "xtls-rprx-vision" { + options.Flow = "xtls-rprx-vision" + } + case "pbk": + TLSOptions.Reality.PublicKey = value + case "sid": + TLSOptions.Reality.ShortID = value + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeVLESS, + Tag: linkURL.Fragment, + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} + +func parseTrojanLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing password") + } + var options option.TrojanOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + options.Password = linkURL.User.Username() + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + proxy[key] = value + } + for key, value := range proxy { + switch key { + case "insecure", "allowInsecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "type": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: map[string]badoption.Listable[string]{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["grpc-service-name"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeTrojan, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseHysteriaLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.HysteriaOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "auth": + options.AuthString = value + case "peer", "sni": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "ca": + TLSOptions.CertificatePath = value + case "ca_str": + TLSOptions.Certificate = strings.Split(value, "\n") + case "up": + options.Up = &byteformats.NetworkBytesCompat{} + options.Up.UnmarshalJSON([]byte(value)) + case "up_mbps": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.Down = &byteformats.NetworkBytesCompat{} + options.Down.UnmarshalJSON([]byte(value)) + case "down_mbps": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs", "obfsParam": + options.Obfs = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseHysteria2Link(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.Hysteria2OutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + Obfs := &option.Hysteria2Obfs{} + options.ServerPort = uint16(443) + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + if linkURL.User != nil { + options.Password = linkURL.User.Username() + } + if linkURL.Port() != "" { + options.ServerPort = StringToType[uint16](linkURL.Port()) + } + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "up": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs": + if value == "salamander" { + Obfs.Type = "salamander" + options.Obfs = Obfs + } + case "obfs-password": + Obfs.Password = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria2, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/provider/parser/parser.go b/provider/parser/parser.go new file mode 100644 index 0000000000..934e7f0495 --- /dev/null +++ b/provider/parser/parser.go @@ -0,0 +1,27 @@ +package parser + +import ( + "context" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +var subscriptionParsers = []func(ctx context.Context, content string) ([]option.Outbound, error){ + ParseBoxSubscription, + ParseClashSubscription, + ParseSIP008Subscription, + ParseRawSubscription, +} + +func ParseSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + var pErr error + for _, parser := range subscriptionParsers { + servers, err := parser(ctx, content) + if len(servers) > 0 { + return servers, nil + } + pErr = E.Errors(pErr, err) + } + return nil, E.Cause(pErr, "no servers found") +} diff --git a/provider/parser/raw.go b/provider/parser/raw.go new file mode 100644 index 0000000000..459de9d268 --- /dev/null +++ b/provider/parser/raw.go @@ -0,0 +1,49 @@ +package parser + +import ( + "context" + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseRawSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + if base64Content, err := DecodeBase64URLSafe(content); err == nil { + servers, _ := parseRawSubscription(base64Content) + if len(servers) > 0 { + return servers, err + } + } + return parseRawSubscription(content) +} + +func parseRawSubscription(content string) ([]option.Outbound, error) { + var servers []option.Outbound + content = strings.ReplaceAll(content, "\r\n", "\n") + linkList := strings.Split(content, "\n") + for _, linkLine := range linkList { + server, err := ParseSubscriptionLink(linkLine) + if err != nil { + continue + } + servers = append(servers, server) + } + if len(servers) == 0 { + return nil, E.New("no servers found") + } + return servers, nil +} + +func DecodeBase64URLSafe(content string) (string, error) { + s := strings.ReplaceAll(content, " ", "-") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "+", "-") + s = strings.ReplaceAll(s, "=", "") + result, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return content, nil + } + return string(result), nil +} diff --git a/provider/parser/sing_box.go b/provider/parser/sing_box.go new file mode 100644 index 0000000000..0883a646e8 --- /dev/null +++ b/provider/parser/sing_box.go @@ -0,0 +1,58 @@ +package parser + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _SingBoxDocument struct { + Outbounds []option.Outbound `json:"outbounds"` +} +type SingBoxDocument _SingBoxDocument + +func (o *SingBoxDocument) UnmarshalJSONContext(ctx context.Context, inputContent []byte) error { + var content badjson.JSONObject + err := content.UnmarshalJSONContext(ctx, inputContent) + if err != nil { + return err + } + outbounds, ok := content.Get("outbounds") + if !ok { + return E.New("missing outbounds in sing-box configuration") + } + var outs badjson.JSONArray + for i, outbound := range outbounds.(badjson.JSONArray) { + typeVal, loaded := outbound.(*badjson.JSONObject).Get("type") + if !loaded { + return E.New("missing type in outbound[", i, "]") + } + switch typeVal.(string) { + case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest: + continue + default: + outs = append(outs, outbound) + } + } + content.Put("outbounds", outs) + inputContent, err = content.MarshalJSONContext(ctx) + if err != nil { + return err + } + return json.UnmarshalContext(ctx, inputContent, (*_SingBoxDocument)(o)) +} + +func ParseBoxSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + options, err := json.UnmarshalExtendedContext[SingBoxDocument](ctx, []byte(content)) + if err != nil { + return nil, err + } + if len(options.Outbounds) == 0 { + return nil, E.New("no servers found") + } + return options.Outbounds, nil +} diff --git a/provider/parser/sip008.go b/provider/parser/sip008.go new file mode 100644 index 0000000000..9d07cd1b5d --- /dev/null +++ b/provider/parser/sip008.go @@ -0,0 +1,53 @@ +package parser + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +type ShadowsocksDocument struct { + Version int `json:"version"` + Servers []ShadowsocksServerDocument `json:"servers"` +} + +type ShadowsocksServerDocument struct { + ID string `json:"id"` + Remarks string `json:"remarks"` + Server string `json:"server"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Method string `json:"method"` + Plugin string `json:"plugin"` + PluginOpts string `json:"plugin_opts"` +} + +func ParseSIP008Subscription(_ context.Context, content string) ([]option.Outbound, error) { + var document ShadowsocksDocument + err := json.Unmarshal([]byte(content), &document) + if err != nil { + return nil, E.Cause(err, "parse SIP008 document") + } + + var servers []option.Outbound + for _, server := range document.Servers { + servers = append(servers, option.Outbound{ + Type: C.TypeShadowsocks, + Tag: server.Remarks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: server.Server, + ServerPort: uint16(server.ServerPort), + }, + Password: server.Password, + Method: server.Method, + Plugin: server.Plugin, + PluginOptions: server.PluginOpts, + }, + }) + } + return servers, nil +} diff --git a/provider/remote/remote.go b/provider/remote/remote.go new file mode 100644 index 0000000000..682210dd81 --- /dev/null +++ b/provider/remote/remote.go @@ -0,0 +1,337 @@ +package remote + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "regexp" + "runtime" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + "github.com/sagernet/sing-box/common/interrupt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/provider/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" +) + +func RegisterProvider(registry *provider.Registry) { + provider.Register[option.ProviderRemoteOptions](registry, C.ProviderTypeRemote, NewProviderRemote) +} + +var _ adapter.Provider = (*ProviderRemote)(nil) + +type ProviderRemote struct { + provider.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + outbound adapter.OutboundManager + provider adapter.ProviderManager + cacheFile adapter.CacheFile + dialer N.Dialer + lastEtag string + lastOutOpts []option.Outbound + lastUpdated time.Time + subscriptionInfo adapter.SubscriptionInfo + ticker *time.Ticker + updating atomic.Bool + + url string + userAgent string + downloadDetour string + updateInterval time.Duration + exclude *regexp.Regexp + include *regexp.Regexp +} + +func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderRemoteOptions) (adapter.Provider, error) { + if options.URL == "" { + return nil, E.New("provider URL is required") + } + updateInterval := time.Duration(options.UpdateInterval) + if updateInterval <= 0 { + updateInterval = 24 * time.Hour + } + if updateInterval < time.Minute { + updateInterval = time.Minute + } + var userAgent string + if options.UserAgent == "" { + userAgent = "sing-box " + C.Version + } else { + userAgent = options.UserAgent + } + ctx, cancel := context.WithCancel(ctx) + outbound := service.FromContext[adapter.OutboundManager](ctx) + logger := logFactory.NewLogger(F.ToString("provider/remote", "[", tag, "]")) + updateChan := make(chan struct{}) + close(updateChan) + return &ProviderRemote{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeRemote, options.HealthCheck), + ctx: ctx, + cancel: cancel, + logger: logger, + outbound: outbound, + provider: service.FromContext[adapter.ProviderManager](ctx), + + url: options.URL, + userAgent: userAgent, + downloadDetour: options.DownloadDetour, + updateInterval: updateInterval, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + }, nil +} + +func (s *ProviderRemote) Start() error { + s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) + if s.cacheFile != nil { + if saveSub := s.cacheFile.LoadSubscription(s.Tag()); saveSub != nil { + content, _ := parser.DecodeBase64URLSafe(string(saveSub.Content)) + firstLine, others := getFirstLine(content) + if info, ok := parseInfo(firstLine); ok { + s.subscriptionInfo = info + content, _ = parser.DecodeBase64URLSafe(others) + } + if err := s.updateProviderFromContent(content); err != nil { + return E.Cause(err, "restore cached outbound provider") + } + s.UpdateGroups() + s.lastUpdated, s.lastEtag = saveSub.LastUpdated, saveSub.LastEtag + } + } + if s.downloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.downloadDetour) + if !loaded { + return E.New("detour outbound not found: ", s.downloadDetour) + } + s.dialer = outbound + } else { + s.dialer = s.outbound.Default() + } + + go s.loopUpdate() + return s.Adapter.Start() +} + +func (s *ProviderRemote) Update() error { + if s.ticker != nil { + s.ticker.Reset(s.updateInterval) + } + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + return s.fetch(ctx) +} + +func (s *ProviderRemote) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderRemote) SubscriptionInfo() adapter.SubscriptionInfo { + return s.subscriptionInfo +} + +func (s *ProviderRemote) Close() error { + s.cancel() + if s.ticker != nil { + s.ticker.Stop() + } + return common.Close(&s.Adapter) +} + +func (s *ProviderRemote) updateOnce() { + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + if err := s.fetch(ctx); err != nil { + s.logger.Error("update outbound provider: ", err) + } +} + +func (s *ProviderRemote) fetch(ctx context.Context) error { + if s.updating.Swap(true) { + return E.New("provider is updating") + } + defer s.updating.Store(false) + s.logger.Debug("updating outbound provider ", s.Tag(), " from URL: ", s.url) + client := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + }, + } + req, err := http.NewRequest(http.MethodGet, s.url, nil) + if err != nil { + return err + } + if s.lastEtag != "" { + req.Header.Set("If-None-Match", s.lastEtag) + } + req.Header.Set("User-Agent", s.userAgent) + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + infoStr := resp.Header.Get("subscription-userinfo") + info, hasInfo := parseInfo(infoStr) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + saveSub := s.cacheFile.LoadSubscription(s.Tag()) + if saveSub != nil { + if hasInfo { + index := bytes.IndexByte(saveSub.Content, '\n') + if index != -1 { + saveSub.Content = append([]byte(infoStr+"\n"), saveSub.Content[index+1:]...) + } + } + saveSub.LastUpdated = s.lastUpdated + err := s.cacheFile.SaveSubscription(s.Tag(), saveSub) + if err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + } + s.logger.Info("update outbound provider ", s.Tag(), ": not modified") + return nil + default: + return E.New("unexpected status: ", resp.Status) + } + defer resp.Body.Close() + contentRaw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + eTagHeader := resp.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader + } + content, _ := parser.DecodeBase64URLSafe(string(contentRaw)) + if !hasInfo { + firstLine, others := getFirstLine(content) + if info, hasInfo = parseInfo(firstLine); hasInfo { + infoStr = firstLine + content, _ = parser.DecodeBase64URLSafe(others) + } + } + if err := s.updateProviderFromContent(content); err != nil { + return err + } + s.UpdateGroups() + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + content, _ := json.Marshal(option.Options{ + Outbounds: s.lastOutOpts, + }) + if hasInfo { + content = append([]byte(infoStr+"\n"), content...) + } + err = s.cacheFile.SaveSubscription(s.Tag(), &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + Content: content, + LastEtag: s.lastEtag, + }) + if err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + s.logger.Info("updated outbound provider ", s.Tag()) + return nil +} + +func (s *ProviderRemote) loopUpdate() { + if time.Since(s.lastUpdated) < s.updateInterval { + select { + case <-s.ctx.Done(): + return + case <-time.After(time.Until(s.lastUpdated.Add(s.updateInterval))): + s.updateOnce() + } + } else { + s.updateOnce() + } + s.ticker = time.NewTicker(s.updateInterval) + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.ticker.C: + s.updateOnce() + } + } +} + +func (s *ProviderRemote) updateProviderFromContent(content string) error { + outboundOpts, err := parser.ParseSubscription(s.ctx, content) + if err != nil { + return err + } + outboundOpts = common.Filter(outboundOpts, func(it option.Outbound) bool { + return (s.exclude == nil || !s.exclude.MatchString(it.Tag)) && (s.include == nil || s.include.MatchString(it.Tag)) + }) + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func getFirstLine(content string) (string, string) { + lines := strings.Split(content, "\n") + if len(lines) == 1 { + return lines[0], "" + } + others := strings.Join(lines[1:], "\n") + return lines[0], others +} + +func parseInfo(infoStr string) (adapter.SubscriptionInfo, bool) { + info := adapter.SubscriptionInfo{} + if infoStr == "" { + return info, false + } + reg := regexp.MustCompile(`(upload|download|total|expire)[\s\t]*=[\s\t]*(-?\d*);?`) + matches := reg.FindAllStringSubmatch(infoStr, 4) + if len(matches) == 0 { + return info, false + } + for _, match := range matches { + key, value := match[1], match[2] + switch key { + case "upload": + info.Upload = parser.StringToType[int64](value) + case "download": + info.Download = parser.StringToType[int64](value) + case "total": + info.Total = parser.StringToType[int64](value) + case "expire": + info.Expire = parser.StringToType[int64](value) + default: + return info, false + } + } + return info, true +} From a932f8c883398159f5182176f2a0f5b8a64f95ef Mon Sep 17 00:00:00 2001 From: reF1nd Date: Thu, 29 Jan 2026 20:06:44 +0800 Subject: [PATCH 25/57] Start DNS transports before providers Move provider startup after DNS transport and router initialization in the Start stage. This ensures DNS resolution is available when remote providers fetch subscriptions during startup. --- box.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/box.go b/box.go index 2701ba2835..323dbc67e2 100644 --- a/box.go +++ b/box.go @@ -492,7 +492,7 @@ func (s *Box) preStart() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.provider, s.network, s.connection, s.router) if err != nil { return err } From 3f2173303b54c7f0901afa9d66844c9cd165e169 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Thu, 11 Sep 2025 18:25:08 +0800 Subject: [PATCH 26/57] Add kTLS support for outbound provider --- provider/parser/clash.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/provider/parser/clash.go b/provider/parser/clash.go index ac999b7c87..db8a44ccf4 100644 --- a/provider/parser/clash.go +++ b/provider/parser/clash.go @@ -578,6 +578,8 @@ type TLSOptions struct { PrivateKey string `yaml:"private-key,omitempty"` ECHOpts *ECHOptions `yaml:"ech-opts,omitempty"` RealityOpts *RealityOptions `yaml:"reality-opts,omitempty"` + KernelTx bool `yaml:"kernel-tx,omitempty"` + KernelRx bool `yaml:"kernel-rx,omitempty"` } func (t *TLSOptions) Build() *option.OutboundTLSOptions { @@ -594,6 +596,8 @@ func (t *TLSOptions) Build() *option.OutboundTLSOptions { CertificatePath: t.CustomCA, ECH: t.ECHOpts.Build(), Reality: t.RealityOpts.Build(), + KernelTx: t.KernelTx, + KernelRx: t.KernelRx, } if strings.HasPrefix(t.Certificate, "-----BEGIN ") { options.ClientCertificate = trimStringArray(strings.Split(t.Certificate, "\n")) From 412e1a942fe138415d62047ec6c57445a420bc79 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Thu, 14 Aug 2025 18:51:50 +0800 Subject: [PATCH 27/57] Add AnyTLS link parser for outbound provider --- provider/parser/link.go | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/provider/parser/link.go b/provider/parser/link.go index a61b9df551..3bce895ade 100644 --- a/provider/parser/link.go +++ b/provider/parser/link.go @@ -36,6 +36,8 @@ func ParseSubscriptionLink(link string) (option.Outbound, error) { return parseHysteriaLink(link) case "hy2", "hysteria2": return parseHysteria2Link(link) + case "anytls": + return parseAnyTLSLink(link) } result[3], _ = DecodeBase64URLSafe(result[3]) link = strings.Join(result[1:], "") @@ -660,3 +662,55 @@ func parseHysteria2Link(link string) (option.Outbound, error) { outbound.Options = &options return outbound, nil } + +func parseAnyTLSLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing password") + } + var options option.AnyTLSOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + options.Password = linkURL.User.Username() + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + proxy[key] = value + } + for key, value := range proxy { + switch key { + case "insecure": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "sni": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeAnyTLS, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} From b6d64ca2cbb2f7caf2c2233073d17819ee34acef Mon Sep 17 00:00:00 2001 From: reF1nd Date: Tue, 17 Jun 2025 22:46:08 +0800 Subject: [PATCH 28/57] Add tls-pin-sha256 support to outbound provider --- provider/parser/clash.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/provider/parser/clash.go b/provider/parser/clash.go index db8a44ccf4..1a64903946 100644 --- a/provider/parser/clash.go +++ b/provider/parser/clash.go @@ -570,6 +570,7 @@ type TLSOptions struct { TLS bool `yaml:"tls,omitempty"` SNI string `yaml:"sni,omitempty"` SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` + Fingerprint string `yaml:"fingerprint,omitempty"` ALPN []string `yaml:"alpn,omitempty"` ClientFingerprint string `yaml:"client-fingerprint,omitempty"` CustomCA string `yaml:"ca,omitempty"` @@ -587,17 +588,18 @@ func (t *TLSOptions) Build() *option.OutboundTLSOptions { return nil } options := &option.OutboundTLSOptions{ - Enabled: t.TLS, - ServerName: t.SNI, - Insecure: t.SkipCertVerify, - ALPN: t.ALPN, - UTLS: clashClientFingerprint(t.ClientFingerprint), - Certificate: trimStringArray(strings.Split(t.CustomCAString, "\n")), - CertificatePath: t.CustomCA, - ECH: t.ECHOpts.Build(), - Reality: t.RealityOpts.Build(), - KernelTx: t.KernelTx, - KernelRx: t.KernelRx, + Enabled: t.TLS, + ServerName: t.SNI, + Insecure: t.SkipCertVerify, + CertificatePinSHA256: t.Fingerprint, + ALPN: t.ALPN, + UTLS: clashClientFingerprint(t.ClientFingerprint), + Certificate: trimStringArray(strings.Split(t.CustomCAString, "\n")), + CertificatePath: t.CustomCA, + ECH: t.ECHOpts.Build(), + Reality: t.RealityOpts.Build(), + KernelTx: t.KernelTx, + KernelRx: t.KernelRx, } if strings.HasPrefix(t.Certificate, "-----BEGIN ") { options.ClientCertificate = trimStringArray(strings.Split(t.Certificate, "\n")) From 281cc22de74bc9578bb2cc96a9e35e9c59cb9a24 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 23 May 2025 17:01:33 +0800 Subject: [PATCH 29/57] Add `override_dialer` option for outbound provider --- docs/configuration/provider/index.md | 10 +- docs/configuration/provider/index.zh.md | 10 +- .../configuration/provider/override_dialer.md | 29 ++++ .../provider/override_dialer.zh.md | 29 ++++ mkdocs.yml | 2 + option/provider.go | 26 ++++ provider/local/lcoal.go | 6 +- provider/parser/parser.go | 131 +++++++++++++++++- provider/remote/remote.go | 6 +- 9 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 docs/configuration/provider/override_dialer.md create mode 100644 docs/configuration/provider/override_dialer.zh.md diff --git a/docs/configuration/provider/index.md b/docs/configuration/provider/index.md index 4ba7b18b85..08d663ea88 100644 --- a/docs/configuration/provider/index.md +++ b/docs/configuration/provider/index.md @@ -18,7 +18,8 @@ List of subscription providers. "url": "", "interval": "", "timeout": "", - } + }, + "override_dialer": {} } ] } @@ -43,7 +44,8 @@ List of subscription providers. "include": "", "user_agent": "", "download_detour": "", - "update_interval": "" + "update_interval": "", + "override_dialer": {} } ] } @@ -87,6 +89,10 @@ Health check interval. The minimum value is `1m`, the default value is `10m`. Health check timeout. the default value is `3s`. +##### override_dialer + +Override dialer fields of outbounds in provider, see [Dialer Fields Override](/configuration/provider/override_dialer/) for details. + ### Local Fields #### path diff --git a/docs/configuration/provider/index.zh.md b/docs/configuration/provider/index.zh.md index c6b2ad4b7d..284ad4fb2d 100644 --- a/docs/configuration/provider/index.zh.md +++ b/docs/configuration/provider/index.zh.md @@ -18,7 +18,8 @@ "url": "", "interval": "", "timeout": "", - } + }, + "override_dialer": {} } ] } @@ -43,7 +44,8 @@ "include": "", "user_agent": "", "download_detour": "", - "update_interval": "" + "update_interval": "", + "override_dialer": {} } ] } @@ -87,6 +89,10 @@ 健康检查的超时时间。默认为 `3s`。 +##### override_dialer + +覆写订阅内容的拨号字段, 参阅 [拨号字段覆写](/zh/configuration/provider/override_dialer/)。 + ### 本地字段 #### path diff --git a/docs/configuration/provider/override_dialer.md b/docs/configuration/provider/override_dialer.md new file mode 100644 index 0000000000..8aa26c519c --- /dev/null +++ b/docs/configuration/provider/override_dialer.md @@ -0,0 +1,29 @@ +### Structure + +```json +{ + "detour": "upstream-out", + "bind_interface": "en0", + "inet4_bind_address": "0.0.0.0", + "inet6_bind_address": "::", + "routing_mark": 1234, + "reuse_addr": false, + "connect_timeout": "5s", + "tcp_fast_open": false, + "tcp_multi_path": false, + "udp_fragment": false, + "domain_resolver": "", // or {} + "network_strategy": "default", + "network_type": [], + "fallback_network_type": [], + "fallback_delay": "300ms", + + // Deprecated + + "domain_strategy": "prefer_ipv6" +} +``` + +### Fields + +`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `domain_strategy` see [Dial Fields](/configuration/shared/dial). diff --git a/docs/configuration/provider/override_dialer.zh.md b/docs/configuration/provider/override_dialer.zh.md new file mode 100644 index 0000000000..3bc40e0c61 --- /dev/null +++ b/docs/configuration/provider/override_dialer.zh.md @@ -0,0 +1,29 @@ +### 结构 + +```json +{ + "detour": "upstream-out", + "bind_interface": "en0", + "inet4_bind_address": "0.0.0.0", + "inet6_bind_address": "::", + "routing_mark": 1234, + "reuse_addr": false, + "connect_timeout": "5s", + "tcp_fast_open": false, + "tcp_multi_path": false, + "udp_fragment": false, + "domain_resolver": "", // 或 {} + "network_strategy": "default", + "network_type": [], + "fallback_network_type": [], + "fallback_delay": "300ms", + + // 废弃的 + + "domain_strategy": "prefer_ipv6" +} +``` + +### 字段 + +`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `domain_strategy` 详情参阅 [拨号字段](/zh/configuration/shared/dial)。 diff --git a/mkdocs.yml b/mkdocs.yml index f75a656adf..f4f5591e94 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -178,6 +178,7 @@ nav: - URLTest: configuration/outbound/urltest.md - Provider: - configuration/provider/index.md + - Dialer Fields Override: configuration/provider/override_dialer.md - Service: - configuration/service/index.md - DERP: configuration/service/derp.md @@ -281,6 +282,7 @@ plugins: Inbound: 入站 Outbound: 出站 Provider: 提供者 + Dialer Fields Override: 拨号字段覆写 Manual: 手册 reconfigure_material: true diff --git a/option/provider.go b/option/provider.go index 656036e4b9..339ff23c62 100644 --- a/option/provider.go +++ b/option/provider.go @@ -49,6 +49,8 @@ func (h *Provider) UnmarshalJSONContext(ctx context.Context, content []byte) err type ProviderLocalOptions struct { Path string `json:"path"` HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + + OverrideDialer *OverrideDialerOptions `json:"override_dialer,omitempty"` } type ProviderRemoteOptions struct { @@ -60,6 +62,8 @@ type ProviderRemoteOptions struct { Exclude *badoption.Regexp `json:"exclude,omitempty"` Include *badoption.Regexp `json:"include,omitempty"` HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + + OverrideDialer *OverrideDialerOptions `json:"override_dialer,omitempty"` } type ProviderInlineOptions struct { @@ -73,3 +77,25 @@ type ProviderHealthCheckOptions struct { Interval badoption.Duration `json:"interval,omitempty"` Timeout badoption.Duration `json:"timeout,omitempty"` } + +type OverrideDialerOptions struct { + Detour *string `json:"detour,omitempty"` + BindInterface *string `json:"bind_interface,omitempty"` + Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` + ProtectPath *string `json:"protect_path,omitempty"` + RoutingMark *FwMark `json:"routing_mark,omitempty"` + ReuseAddr *bool `json:"reuse_addr,omitempty"` + ConnectTimeout *badoption.Duration `json:"connect_timeout,omitempty"` + TCPFastOpen *bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath *bool `json:"tcp_multi_path,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + NetworkType *badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + FallbackNetworkType *badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` + FallbackDelay *badoption.Duration `json:"fallback_delay,omitempty"` + + // Deprecated: migrated to domain resolver + DomainStrategy *DomainStrategy `json:"domain_strategy,omitempty"` +} diff --git a/provider/local/lcoal.go b/provider/local/lcoal.go index 63a4fdad16..00b670e040 100644 --- a/provider/local/lcoal.go +++ b/provider/local/lcoal.go @@ -39,6 +39,8 @@ type ProviderLocal struct { lastOutOpts []option.Outbound lastUpdated time.Time watcher *fswatch.Watcher + + overrideDialer *option.OverrideDialerOptions } func NewProviderInline(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderInlineOptions) (adapter.Provider, error) { @@ -68,6 +70,8 @@ func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log ctx: ctx, logger: logger, provider: service.FromContext[adapter.ProviderManager](ctx), + + overrideDialer: options.OverrideDialer, } filePath := filemanager.BasePath(ctx, options.Path) provider.path, _ = filepath.Abs(filePath) @@ -115,7 +119,7 @@ func (s *ProviderLocal) reloadFile(path string) error { if err != nil { return err } - outboundOpts, err := parser.ParseSubscription(s.ctx, string(content)) + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content), s.overrideDialer) if err != nil { return err } diff --git a/provider/parser/parser.go b/provider/parser/parser.go index 934e7f0495..5bb4dc1967 100644 --- a/provider/parser/parser.go +++ b/provider/parser/parser.go @@ -2,8 +2,11 @@ package parser import ( "context" + "reflect" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" ) @@ -14,14 +17,138 @@ var subscriptionParsers = []func(ctx context.Context, content string) ([]option. ParseRawSubscription, } -func ParseSubscription(ctx context.Context, content string) ([]option.Outbound, error) { +func ParseSubscription(ctx context.Context, content string, overrideDialerOptions *option.OverrideDialerOptions) ([]option.Outbound, error) { var pErr error for _, parser := range subscriptionParsers { servers, err := parser(ctx, content) if len(servers) > 0 { - return servers, nil + return overrideOutbounds(servers, overrideDialerOptions), nil } pErr = E.Errors(pErr, err) } return nil, E.Cause(pErr, "no servers found") } + +func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *option.OverrideDialerOptions) []option.Outbound { + var tags []string + for _, outbound := range outbounds { + tags = append(tags, outbound.Tag) + } + var parsedOutbounds []option.Outbound + for _, outbound := range outbounds { + switch outbound.Type { + case C.TypeHTTP: + options := outbound.Options.(*option.HTTPOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeSOCKS: + options := outbound.Options.(*option.SOCKSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeTUIC: + options := outbound.Options.(*option.TUICOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeVMess: + options := outbound.Options.(*option.VMessOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeVLESS: + options := outbound.Options.(*option.VLESSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeTrojan: + options := outbound.Options.(*option.TrojanOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeHysteria: + options := outbound.Options.(*option.HysteriaOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeShadowTLS: + options := outbound.Options.(*option.ShadowTLSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeHysteria2: + options := outbound.Options.(*option.Hysteria2OutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeAnyTLS: + options := outbound.Options.(*option.AnyTLSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + case C.TypeShadowsocks: + options := outbound.Options.(*option.ShadowsocksOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + outbound.Options = options + } + parsedOutbounds = append(parsedOutbounds, outbound) + } + return parsedOutbounds +} + +func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *option.OverrideDialerOptions, tags []string) option.DialerOptions { + if options.Detour != "" && !common.Any(tags, func(tag string) bool { + return options.Detour == tag + }) { + options.Detour = "" + } + var defaultOptions option.OverrideDialerOptions + if overrideDialerOptions == nil || reflect.DeepEqual(*overrideDialerOptions, defaultOptions) { + return options + } + if overrideDialerOptions.Detour != nil && options.Detour == "" { + options.Detour = *overrideDialerOptions.Detour + } + if overrideDialerOptions.BindInterface != nil { + options.BindInterface = *overrideDialerOptions.BindInterface + } + if overrideDialerOptions.Inet4BindAddress != nil { + options.Inet4BindAddress = overrideDialerOptions.Inet4BindAddress + } + if overrideDialerOptions.Inet6BindAddress != nil { + options.Inet6BindAddress = overrideDialerOptions.Inet6BindAddress + } + if overrideDialerOptions.ProtectPath != nil { + options.ProtectPath = *overrideDialerOptions.ProtectPath + } + if overrideDialerOptions.RoutingMark != nil { + options.RoutingMark = *overrideDialerOptions.RoutingMark + } + if overrideDialerOptions.ReuseAddr != nil { + options.ReuseAddr = *overrideDialerOptions.ReuseAddr + } + if overrideDialerOptions.ConnectTimeout != nil { + options.ConnectTimeout = *overrideDialerOptions.ConnectTimeout + } + if overrideDialerOptions.TCPFastOpen != nil { + options.TCPFastOpen = *overrideDialerOptions.TCPFastOpen + } + if overrideDialerOptions.TCPMultiPath != nil { + options.TCPMultiPath = *overrideDialerOptions.TCPMultiPath + } + if overrideDialerOptions.UDPFragment != nil { + options.UDPFragment = overrideDialerOptions.UDPFragment + } + if overrideDialerOptions.DomainResolver != nil { + options.DomainResolver = overrideDialerOptions.DomainResolver + } + if overrideDialerOptions.NetworkStrategy != nil { + options.NetworkStrategy = overrideDialerOptions.NetworkStrategy + } + if overrideDialerOptions.NetworkType != nil { + options.NetworkType = *overrideDialerOptions.NetworkType + } + if overrideDialerOptions.FallbackNetworkType != nil { + options.FallbackNetworkType = *overrideDialerOptions.FallbackNetworkType + } + if overrideDialerOptions.FallbackDelay != nil { + options.FallbackDelay = *overrideDialerOptions.FallbackDelay + } + + //nolint:staticcheck + if overrideDialerOptions.DomainStrategy != nil { + options.DomainStrategy = *overrideDialerOptions.DomainStrategy + } + return options +} diff --git a/provider/remote/remote.go b/provider/remote/remote.go index 682210dd81..fa0427adb4 100644 --- a/provider/remote/remote.go +++ b/provider/remote/remote.go @@ -58,6 +58,8 @@ type ProviderRemote struct { updateInterval time.Duration exclude *regexp.Regexp include *regexp.Regexp + + overrideDialer *option.OverrideDialerOptions } func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderRemoteOptions) (adapter.Provider, error) { @@ -96,6 +98,8 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo updateInterval: updateInterval, exclude: (*regexp.Regexp)(options.Exclude), include: (*regexp.Regexp)(options.Include), + + overrideDialer: options.OverrideDialer, }, nil } @@ -287,7 +291,7 @@ func (s *ProviderRemote) loopUpdate() { } func (s *ProviderRemote) updateProviderFromContent(content string) error { - outboundOpts, err := parser.ParseSubscription(s.ctx, content) + outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer) if err != nil { return err } From 1409579e8a6267f26818f39d94f61068e81ef191 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 18 Apr 2025 17:19:48 +0800 Subject: [PATCH 30/57] Add `path` option for remote provider --- adapter/experimental.go | 27 +++++ common/hash/hash.go | 62 ++++++++++++ experimental/cachefile/cache.go | 16 +-- option/provider.go | 1 + provider/remote/remote.go | 169 ++++++++++++++++++++++++++------ 5 files changed, 240 insertions(+), 35 deletions(-) create mode 100644 common/hash/hash.go diff --git a/adapter/experimental.go b/adapter/experimental.go index 7f428573f6..e6077ebce2 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -7,6 +7,7 @@ import ( "io" "time" + "github.com/sagernet/sing-box/common/hash" "github.com/sagernet/sing/common/observable" "github.com/sagernet/sing/common/varbin" ) @@ -62,6 +63,7 @@ type CacheFile interface { } type SavedBinary struct { + Hash hash.HashType Content []byte LastUpdated time.Time LastEtag string @@ -73,6 +75,18 @@ func (s *SavedBinary) MarshalBinary() ([]byte, error) { if err != nil { return nil, err } + hash, err := s.Hash.MarshalBinary() + if err != nil { + return nil, err + } + _, err = varbin.WriteUvarint(&buffer, uint64(len(hash))) + if err != nil { + return nil, err + } + _, err = buffer.Write(hash) + if err != nil { + return nil, err + } _, err = varbin.WriteUvarint(&buffer, uint64(len(s.Content))) if err != nil { return nil, err @@ -103,6 +117,19 @@ func (s *SavedBinary) UnmarshalBinary(data []byte) error { if err != nil { return err } + hashLength, err := binary.ReadUvarint(reader) + if err != nil { + return err + } + hash := make([]byte, hashLength) + _, err = io.ReadFull(reader, hash) + if err != nil { + return err + } + err = s.Hash.UnmarshalBinary(hash) + if err != nil { + return err + } contentLength, err := binary.ReadUvarint(reader) if err != nil { return err diff --git a/common/hash/hash.go b/common/hash/hash.go new file mode 100644 index 0000000000..b5f7744e9a --- /dev/null +++ b/common/hash/hash.go @@ -0,0 +1,62 @@ +package hash + +import ( + "crypto/md5" + "encoding/hex" + "errors" +) + +// HashType warps hash array inside struct +// someday can change to other hash algorithm simply +type HashType struct { + md5 [md5.Size]byte // MD5 +} + +func MakeHash(data []byte) HashType { + return HashType{md5.Sum(data)} +} + +func (h HashType) Equal(hash HashType) bool { + return h.md5 == hash.md5 +} + +func (h HashType) Bytes() []byte { + return h.md5[:] +} + +func (h HashType) String() string { + return hex.EncodeToString(h.Bytes()) +} + +func (h HashType) MarshalText() ([]byte, error) { + return []byte(h.String()), nil +} + +func (h *HashType) UnmarshalText(data []byte) error { + if hex.DecodedLen(len(data)) != md5.Size { + return errors.New("invalid hash length") + } + _, err := hex.Decode(h.md5[:], data) + return err +} + +func (h HashType) MarshalBinary() ([]byte, error) { + return h.md5[:], nil +} + +func (h *HashType) UnmarshalBinary(data []byte) error { + if len(data) != md5.Size { + return errors.New("invalid hash length") + } + copy(h.md5[:], data) + return nil +} + +func (h HashType) Len() int { + return len(h.md5) +} + +func (h HashType) IsValid() bool { + var zero HashType + return h != zero +} diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index c95081b061..1fa47b217a 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -19,11 +19,12 @@ import ( ) var ( - bucketSelected = []byte("selected") - bucketExpand = []byte("group_expand") - bucketMode = []byte("clash_mode") - bucketRuleSet = []byte("rule_set") - bucketExternalUI = []byte("external_ui") + bucketSelected = []byte("selected") + bucketExpand = []byte("group_expand") + bucketMode = []byte("clash_mode") + bucketRuleSet = []byte("rule_set") + bucketExternalUI = []byte("external_ui") + bucketOutboundProvider = []byte("outbound_provider") bucketNameList = []string{ string(bucketSelected), @@ -31,6 +32,7 @@ var ( string(bucketMode), string(bucketRuleSet), string(bucketExternalUI), + string(bucketOutboundProvider), string(bucketRDRC), } @@ -398,7 +400,7 @@ func (c *CacheFile) SaveExternalUI(tag string, info *adapter.SavedBinary) error func (c *CacheFile) LoadSubscription(tag string) *adapter.SavedBinary { var savedSet adapter.SavedBinary err := c.DB.View(func(t *bbolt.Tx) error { - bucket := c.bucket(t, bucketRuleSet) + bucket := c.bucket(t, bucketOutboundProvider) if bucket == nil { return os.ErrNotExist } @@ -416,7 +418,7 @@ func (c *CacheFile) LoadSubscription(tag string) *adapter.SavedBinary { func (c *CacheFile) SaveSubscription(tag string, sub *adapter.SavedBinary) error { return c.DB.Batch(func(t *bbolt.Tx) error { - bucket, err := c.createBucket(t, bucketRuleSet) + bucket, err := c.createBucket(t, bucketOutboundProvider) if err != nil { return err } diff --git a/option/provider.go b/option/provider.go index 339ff23c62..6006157a88 100644 --- a/option/provider.go +++ b/option/provider.go @@ -55,6 +55,7 @@ type ProviderLocalOptions struct { type ProviderRemoteOptions struct { URL string `json:"url"` + Path string `json:"path,omitempty"` UserAgent string `json:"user_agent,omitempty"` DownloadDetour string `json:"download_detour,omitempty"` UpdateInterval badoption.Duration `json:"update_interval,omitempty"` diff --git a/provider/remote/remote.go b/provider/remote/remote.go index fa0427adb4..8575ae7444 100644 --- a/provider/remote/remote.go +++ b/provider/remote/remote.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "crypto/tls" + "fmt" "io" "net" "net/http" + "os" + "path/filepath" "regexp" "runtime" "strings" @@ -15,6 +18,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/provider" + "github.com/sagernet/sing-box/common/hash" "github.com/sagernet/sing-box/common/interrupt" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -27,7 +31,9 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) func RegisterProvider(registry *provider.Registry) { @@ -45,6 +51,7 @@ type ProviderRemote struct { provider adapter.ProviderManager cacheFile adapter.CacheFile dialer N.Dialer + hash hash.HashType lastEtag string lastOutOpts []option.Outbound lastUpdated time.Time @@ -53,6 +60,7 @@ type ProviderRemote struct { updating atomic.Bool url string + path string userAgent string downloadDetour string updateInterval time.Duration @@ -66,12 +74,20 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo if options.URL == "" { return nil, E.New("provider URL is required") } + var path string + if options.Path != "" { + path = filemanager.BasePath(ctx, options.Path) + path, _ = filepath.Abs(path) + } + if rw.IsDir(path) { + return nil, E.New("provider path is a directory: ", path) + } updateInterval := time.Duration(options.UpdateInterval) if updateInterval <= 0 { updateInterval = 24 * time.Hour } - if updateInterval < time.Minute { - updateInterval = time.Minute + if updateInterval < time.Hour { + updateInterval = time.Hour } var userAgent string if options.UserAgent == "" { @@ -93,6 +109,7 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo provider: service.FromContext[adapter.ProviderManager](ctx), url: options.URL, + path: path, userAgent: userAgent, downloadDetour: options.DownloadDetour, updateInterval: updateInterval, @@ -105,20 +122,9 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo func (s *ProviderRemote) Start() error { s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) - if s.cacheFile != nil { - if saveSub := s.cacheFile.LoadSubscription(s.Tag()); saveSub != nil { - content, _ := parser.DecodeBase64URLSafe(string(saveSub.Content)) - firstLine, others := getFirstLine(content) - if info, ok := parseInfo(firstLine); ok { - s.subscriptionInfo = info - content, _ = parser.DecodeBase64URLSafe(others) - } - if err := s.updateProviderFromContent(content); err != nil { - return E.Cause(err, "restore cached outbound provider") - } - s.UpdateGroups() - s.lastUpdated, s.lastEtag = saveSub.LastUpdated, saveSub.LastEtag - } + err := s.loadCacheFile() + if err != nil { + return E.Cause(err, "restore cached outbound provider") } if s.downloadDetour != "" { outbound, loaded := s.outbound.Outbound(s.downloadDetour) @@ -206,19 +212,26 @@ func (s *ProviderRemote) fetch(ctx context.Context) error { if s.cacheFile != nil { saveSub := s.cacheFile.LoadSubscription(s.Tag()) if saveSub != nil { - if hasInfo { + if s.path != "" { + saveSub.Hash = s.hash + } else if hasInfo { index := bytes.IndexByte(saveSub.Content, '\n') if index != -1 { saveSub.Content = append([]byte(infoStr+"\n"), saveSub.Content[index+1:]...) } } saveSub.LastUpdated = s.lastUpdated - err := s.cacheFile.SaveSubscription(s.Tag(), saveSub) - if err != nil { + if err := s.cacheFile.SaveSubscription(s.Tag(), saveSub); err != nil { s.logger.Error("save outbound provider cache file: ", err) } } } + if s.path != "" { + content, _ := json.Marshal(option.Options{ + Outbounds: s.lastOutOpts, + }) + s.saveCacheFile(hasInfo, info, content) + } s.logger.Info("update outbound provider ", s.Tag(), ": not modified") return nil default: @@ -247,26 +260,107 @@ func (s *ProviderRemote) fetch(ctx context.Context) error { s.UpdateGroups() s.subscriptionInfo = info s.lastUpdated = time.Now() - if s.cacheFile != nil { + if s.path != "" || s.cacheFile != nil { content, _ := json.Marshal(option.Options{ Outbounds: s.lastOutOpts, }) - if hasInfo { + if s.path != "" { + s.saveCacheFile(hasInfo, info, content) + } else if hasInfo { content = append([]byte(infoStr+"\n"), content...) } - err = s.cacheFile.SaveSubscription(s.Tag(), &adapter.SavedBinary{ - LastUpdated: s.lastUpdated, - Content: content, - LastEtag: s.lastEtag, - }) - if err != nil { - s.logger.Error("save outbound provider cache file: ", err) + if s.cacheFile != nil { + saveSub := &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + LastEtag: s.lastEtag, + } + if s.path != "" { + saveSub.Hash = s.hash + } else { + saveSub.Content = content + } + if err = s.cacheFile.SaveSubscription(s.Tag(), saveSub); err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } } } s.logger.Info("updated outbound provider ", s.Tag()) return nil } +func (s *ProviderRemote) loadCacheFile() error { + var content []byte + var lastUpdated time.Time + var lastEtag string + var saveSub *adapter.SavedBinary + if s.cacheFile != nil { + if saveSub = s.cacheFile.LoadSubscription(s.Tag()); saveSub != nil { + s.hash = saveSub.Hash + } + } + if s.path != "" { + exists, err := pathExists(s.path) + if err != nil { + return err + } + if !exists { + return nil + } + file, _ := os.Open(s.path) + content, err = io.ReadAll(file) + if err != nil { + return err + } + if saveSub != nil { + if !s.hash.Equal(hash.MakeHash(content)) { + s.logger.Error("load outbound provider cache file failed: validation failed") + return nil + } + lastUpdated = saveSub.LastUpdated + lastEtag = saveSub.LastEtag + } else { + fs, _ := file.Stat() + lastUpdated = fs.ModTime() + } + } else if saveSub != nil && saveSub.Content != nil { + content = saveSub.Content + lastUpdated = saveSub.LastUpdated + lastEtag = saveSub.LastEtag + } else { + return nil + } + if err := s.loadFromContent(content); err != nil { + return err + } + s.UpdateGroups() + s.lastUpdated, s.lastEtag = lastUpdated, lastEtag + return nil +} + +func (s *ProviderRemote) loadFromContent(contentRaw []byte) error { + content, _ := parser.DecodeBase64URLSafe(string(contentRaw)) + firstLine, others := getFirstLine(content) + if info, ok := parseInfo(firstLine); ok { + s.subscriptionInfo = info + content, _ = parser.DecodeBase64URLSafe(others) + } + if err := s.updateProviderFromContent(content); err != nil { + return err + } + return nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + func (s *ProviderRemote) loopUpdate() { if time.Since(s.lastUpdated) < s.updateInterval { select { @@ -290,6 +384,25 @@ func (s *ProviderRemote) loopUpdate() { } } +func (s *ProviderRemote) saveCacheFile(hasInfo bool, info adapter.SubscriptionInfo, contentRaw []byte) { + content := contentRaw + if hasInfo { + infoStr := fmt.Sprint( + "# upload=", info.Upload, + "; download=", info.Download, + "; total=", info.Total, + "; expire=", info.Expire, + ";") + content = append([]byte(infoStr+"\n"), content...) + } + s.hash = hash.MakeHash(content) + dir := filepath.Dir(s.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + filemanager.MkdirAll(s.ctx, dir, 0o755) + } + filemanager.WriteFile(s.ctx, s.path, []byte(content), 0o666) +} + func (s *ProviderRemote) updateProviderFromContent(content string) error { outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer) if err != nil { From fb78a14255c97918ffc1f27386bc2a6b7af55bdd Mon Sep 17 00:00:00 2001 From: reF1nd Date: Sun, 2 Nov 2025 19:10:30 +0800 Subject: [PATCH 31/57] provider: Fix clash `tls` parser --- provider/parser/clash.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/parser/clash.go b/provider/parser/clash.go index 1a64903946..e547f6a134 100644 --- a/provider/parser/clash.go +++ b/provider/parser/clash.go @@ -584,7 +584,7 @@ type TLSOptions struct { } func (t *TLSOptions) Build() *option.OutboundTLSOptions { - if t == nil { + if t == nil || !t.TLS { return nil } options := &option.OutboundTLSOptions{ From fd9a24b50d1b3d457bb867f1041126916cdbfea3 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 5 Dec 2025 00:59:26 +0800 Subject: [PATCH 32/57] provider: Fix slow startup --- adapter/provider/manager.go | 29 ++++++++++++++++++------- protocol/group/urltest.go | 31 ++++++++++++++++---------- provider/local/lcoal.go | 20 +++++++++-------- provider/remote/remote.go | 43 +++++++++++++++++++++++-------------- 4 files changed, 79 insertions(+), 44 deletions(-) diff --git a/adapter/provider/manager.go b/adapter/provider/manager.go index 563df8dac5..549f771040 100644 --- a/adapter/provider/manager.go +++ b/adapter/provider/manager.go @@ -48,11 +48,20 @@ func (m *Manager) Start(stage adapter.StartStage) error { m.stage = stage providers := m.providers m.access.Unlock() - for _, provider := range providers { - err := adapter.LegacyStart(provider, stage) - if err != nil { - return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + if stage == adapter.StartStateStart && len(providers) > 0 { + startContext := adapter.NewHTTPStartContext(context.Background()) + defer startContext.Close() + for _, provider := range providers { + if contextStarter, ok := provider.(interface { + StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error + }); ok { + err := contextStarter.StartContext(context.Background(), startContext) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } } + return nil } return nil } @@ -129,10 +138,14 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logFactory m.access.Lock() defer m.access.Unlock() if m.started { - for _, stage := range adapter.ListStartStages { - err = adapter.LegacyStart(provider, stage) - if err != nil { - return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + if m.stage >= adapter.StartStateStart { + if contextStarter, ok := provider.(interface { + StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error + }); ok { + err = contextStarter.StartContext(context.Background(), nil) + if err != nil { + return E.Cause(err, "start provider/", provider.Type(), "[", provider.Tag(), "]") + } } } } diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 682dc74dbf..376fa87cc3 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -15,7 +15,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/batch" E "github.com/sagernet/sing/common/exceptions" @@ -161,6 +161,13 @@ func (s *URLTest) CheckOutbounds() { s.group.CheckOutbounds(true) } +func (s *URLTest) isGroupActive() bool { + if !s.group.started { + return false + } + return time.Since(s.group.lastActive.Load()) <= s.group.idleTimeout +} + func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { s.group.Touch() var outbound adapter.Outbound @@ -273,17 +280,19 @@ func (s *URLTest) onProviderUpdated(tag string) error { outbounds = append(outbounds, detour) } s.tags, s.group.outbounds = tags, outbounds - s.group.access.Lock() - if s.group.ticker != nil { - s.group.ticker.Reset(s.group.interval) - } - s.group.access.Unlock() - ctx, cancel := context.WithCancel(s.ctx) - if s.cancel != nil { - s.cancel() + if s.isGroupActive() { + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) } - s.cancel = cancel - s.URLTest(ctx) return nil } diff --git a/provider/local/lcoal.go b/provider/local/lcoal.go index 00b670e040..b95bd07bb7 100644 --- a/provider/local/lcoal.go +++ b/provider/local/lcoal.go @@ -92,16 +92,18 @@ func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log return provider, nil } -func (s *ProviderLocal) Start() error { - err := s.reloadFile(s.path) - if err != nil { - return err - } - s.UpdateGroups() - if s.watcher != nil { - err := s.watcher.Start() +func (s *ProviderLocal) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { + if s.path != "" { + err := s.reloadFile(s.path) if err != nil { - s.logger.Error(E.Cause(err, "watch provider file")) + return err + } + s.UpdateGroups() + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + s.logger.Error(E.Cause(err, "watch provider file")) + } } } return s.Adapter.Start() diff --git a/provider/remote/remote.go b/provider/remote/remote.go index 8575ae7444..9083d6bc5d 100644 --- a/provider/remote/remote.go +++ b/provider/remote/remote.go @@ -120,7 +120,7 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo }, nil } -func (s *ProviderRemote) Start() error { +func (s *ProviderRemote) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) err := s.loadCacheFile() if err != nil { @@ -135,7 +135,13 @@ func (s *ProviderRemote) Start() error { } else { s.dialer = s.outbound.Default() } - + if s.lastUpdated.IsZero() { + ctx = interrupt.ContextWithIsProviderConnection(ctx) + err := s.fetch(ctx, startContext) + if err != nil { + return E.Cause(err, "initial outbound provider: ", s.Tag()) + } + } go s.loopUpdate() return s.Adapter.Start() } @@ -145,7 +151,7 @@ func (s *ProviderRemote) Update() error { s.ticker.Reset(s.updateInterval) } ctx := interrupt.ContextWithIsProviderConnection(s.ctx) - return s.fetch(ctx) + return s.fetch(ctx, nil) } func (s *ProviderRemote) UpdatedAt() time.Time { @@ -166,29 +172,34 @@ func (s *ProviderRemote) Close() error { func (s *ProviderRemote) updateOnce() { ctx := interrupt.ContextWithIsProviderConnection(s.ctx) - if err := s.fetch(ctx); err != nil { + if err := s.fetch(ctx, nil); err != nil { s.logger.Error("update outbound provider: ", err) } } -func (s *ProviderRemote) fetch(ctx context.Context) error { +func (s *ProviderRemote) fetch(ctx context.Context, startContext *adapter.HTTPStartContext) error { if s.updating.Swap(true) { return E.New("provider is updating") } defer s.updating.Store(false) s.logger.Debug("updating outbound provider ", s.Tag(), " from URL: ", s.url) - client := &http.Client{ - Transport: &http.Transport{ - ForceAttemptHTTP2: true, - TLSHandshakeTimeout: C.TCPTimeout, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) - }, - TLSClientConfig: &tls.Config{ - Time: ntp.TimeFuncFromContext(ctx), - RootCAs: adapter.RootPoolFromContext(ctx), + var client *http.Client + if startContext != nil { + client = startContext.HTTPClient(s.downloadDetour, s.dialer) + } else { + client = &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, }, - }, + } } req, err := http.NewRequest(http.MethodGet, s.url, nil) if err != nil { From 5a2a66cf84cfe6eb6e29614bf986fd783a19b29a Mon Sep 17 00:00:00 2001 From: reF1nd Date: Tue, 3 Mar 2026 22:59:05 +0800 Subject: [PATCH 33/57] provider: Rewrite detour tags to use provider-prefixed names --- adapter/provider/adapter.go | 18 +++++++++++++++ provider/local/lcoal.go | 3 ++- provider/parser/parser.go | 44 +++++++++++++++++++++---------------- provider/remote/remote.go | 7 ++++-- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/adapter/provider/adapter.go b/adapter/provider/adapter.go index 3c55783e80..a3a96e7847 100644 --- a/adapter/provider/adapter.go +++ b/adapter/provider/adapter.go @@ -243,6 +243,24 @@ func (a *Adapter) healthcheck(ctx context.Context) (map[string]uint16, error) { return result, nil } +func (a *Adapter) RewriteDetourForProvider(opts []option.Outbound) { + tagMapping := make(map[string]string) + for _, opt := range opts { + if opt.Tag != "" { + tagMapping[opt.Tag] = F.ToString(a.providerTag, "/", opt.Tag) + } + } + for _, opt := range opts { + if dialerWrapper, ok := opt.Options.(option.DialerOptionsWrapper); ok { + dialerOptions := dialerWrapper.TakeDialerOptions() + if newDetour, found := tagMapping[dialerOptions.Detour]; found { + dialerOptions.Detour = newDetour + dialerWrapper.ReplaceDialerOptions(dialerOptions) + } + } + } +} + func (a *Adapter) removeUseless(newOpts []option.Outbound) { if len(a.outbounds) == 0 { return diff --git a/provider/local/lcoal.go b/provider/local/lcoal.go index b95bd07bb7..6f9f228b75 100644 --- a/provider/local/lcoal.go +++ b/provider/local/lcoal.go @@ -53,6 +53,7 @@ func NewProviderInline(ctx context.Context, router adapter.Router, logFactory lo ctx: ctx, logger: logger, } + provider.RewriteDetourForProvider(options.Outbounds) provider.UpdateOutbounds(nil, options.Outbounds) return provider, nil } @@ -121,7 +122,7 @@ func (s *ProviderLocal) reloadFile(path string) error { if err != nil { return err } - outboundOpts, err := parser.ParseSubscription(s.ctx, string(content), s.overrideDialer) + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content), s.overrideDialer, s.Tag()) if err != nil { return err } diff --git a/provider/parser/parser.go b/provider/parser/parser.go index 5bb4dc1967..e2242bbd6f 100644 --- a/provider/parser/parser.go +++ b/provider/parser/parser.go @@ -17,19 +17,19 @@ var subscriptionParsers = []func(ctx context.Context, content string) ([]option. ParseRawSubscription, } -func ParseSubscription(ctx context.Context, content string, overrideDialerOptions *option.OverrideDialerOptions) ([]option.Outbound, error) { +func ParseSubscription(ctx context.Context, content string, overrideDialerOptions *option.OverrideDialerOptions, providerTag string) ([]option.Outbound, error) { var pErr error for _, parser := range subscriptionParsers { servers, err := parser(ctx, content) if len(servers) > 0 { - return overrideOutbounds(servers, overrideDialerOptions), nil + return overrideOutbounds(servers, overrideDialerOptions, providerTag), nil } pErr = E.Errors(pErr, err) } return nil, E.Cause(pErr, "no servers found") } -func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *option.OverrideDialerOptions) []option.Outbound { +func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *option.OverrideDialerOptions, providerTag string) []option.Outbound { var tags []string for _, outbound := range outbounds { tags = append(tags, outbound.Tag) @@ -39,47 +39,47 @@ func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *optio switch outbound.Type { case C.TypeHTTP: options := outbound.Options.(*option.HTTPOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeSOCKS: options := outbound.Options.(*option.SOCKSOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeTUIC: options := outbound.Options.(*option.TUICOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeVMess: options := outbound.Options.(*option.VMessOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeVLESS: options := outbound.Options.(*option.VLESSOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeTrojan: options := outbound.Options.(*option.TrojanOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeHysteria: options := outbound.Options.(*option.HysteriaOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeShadowTLS: options := outbound.Options.(*option.ShadowTLSOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeHysteria2: options := outbound.Options.(*option.Hysteria2OutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeAnyTLS: options := outbound.Options.(*option.AnyTLSOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options case C.TypeShadowsocks: options := outbound.Options.(*option.ShadowsocksOutboundOptions) - options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) outbound.Options = options } parsedOutbounds = append(parsedOutbounds, outbound) @@ -87,11 +87,17 @@ func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *optio return parsedOutbounds } -func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *option.OverrideDialerOptions, tags []string) option.DialerOptions { - if options.Detour != "" && !common.Any(tags, func(tag string) bool { - return options.Detour == tag - }) { - options.Detour = "" +func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *option.OverrideDialerOptions, tags []string, providerTag string) option.DialerOptions { + if options.Detour != "" { + if common.Any(tags, func(tag string) bool { + return options.Detour == tag + }) { + if providerTag != "" { + options.Detour = providerTag + "/" + options.Detour + } + } else { + options.Detour = "" + } } var defaultOptions option.OverrideDialerOptions if overrideDialerOptions == nil || reflect.DeepEqual(*overrideDialerOptions, defaultOptions) { diff --git a/provider/remote/remote.go b/provider/remote/remote.go index 9083d6bc5d..3d94457142 100644 --- a/provider/remote/remote.go +++ b/provider/remote/remote.go @@ -355,9 +355,12 @@ func (s *ProviderRemote) loadFromContent(contentRaw []byte) error { s.subscriptionInfo = info content, _ = parser.DecodeBase64URLSafe(others) } - if err := s.updateProviderFromContent(content); err != nil { + outboundOpts, err := parser.ParseBoxSubscription(s.ctx, content) + if err != nil { return err } + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts return nil } @@ -415,7 +418,7 @@ func (s *ProviderRemote) saveCacheFile(hasInfo bool, info adapter.SubscriptionIn } func (s *ProviderRemote) updateProviderFromContent(content string) error { - outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer) + outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer, s.Tag()) if err != nil { return err } From 7337caaa279c9189c0f1a9a9d2d8ab340c1d8d97 Mon Sep 17 00:00:00 2001 From: yaotthaha Date: Tue, 10 Oct 2023 10:40:10 +0800 Subject: [PATCH 34/57] Add reload support (cherry picked from commit https://github.com/rnetx/sing-box/commit/e98000c371095c1d5f7fcec4a02ffaaf22a68e47) --- adapter/router.go | 2 ++ box.go | 9 ++++++++- cmd/sing-box/cmd_run.go | 17 ++++++++++++++--- experimental/clashapi/configs.go | 3 ++- experimental/clashapi/reload.go | 19 +++++++++++++++++++ experimental/clashapi/reload_stub.go | 19 +++++++++++++++++++ route/router.go | 13 ++++++++++++- 7 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 experimental/clashapi/reload.go create mode 100644 experimental/clashapi/reload_stub.go diff --git a/adapter/router.go b/adapter/router.go index 5b917b7efe..1f05380a8a 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -31,6 +31,8 @@ type Router interface { AppendTracker(tracker ConnectionTracker) ResetNetwork() DefaultDomainMatchStrategy() C.DomainMatchStrategy + + Reload() } type ConnectionTracker interface { diff --git a/box.go b/box.go index 323dbc67e2..af3b027f66 100644 --- a/box.go +++ b/box.go @@ -52,6 +52,7 @@ type Box struct { connection *route.ConnectionManager router *route.Router internalService []adapter.LifecycleService + reloadChan chan struct{} done chan struct{} } @@ -103,6 +104,7 @@ func Context( func New(options Options) (*Box, error) { createdAt := time.Now() + reloadChan := make(chan struct{}, 1) ctx := options.Context if ctx == nil { ctx = context.Background() @@ -207,7 +209,7 @@ func New(options Options) (*Box, error) { service.MustRegister[adapter.NetworkManager](ctx, networkManager) connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection")) service.MustRegister[adapter.ConnectionManager](ctx, connectionManager) - router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions) + router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions, reloadChan) service.MustRegister[adapter.Router](ctx, router) err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet) if err != nil { @@ -434,6 +436,7 @@ func New(options Options) (*Box, error) { logFactory: logFactory, logger: logFactory.Logger(), internalService: internalServices, + reloadChan: reloadChan, done: make(chan struct{}), }, nil } @@ -597,3 +600,7 @@ func (s *Box) Outbound() adapter.OutboundManager { func (s *Box) LogFactory() log.Factory { return s.logFactory } + +func (s *Box) ReloadChan() <-chan struct{} { + return s.reloadChan +} diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index f31db9dc82..1288e051e4 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -177,20 +177,31 @@ func run() error { } runtimeDebug.FreeOSMemory() for { - osSignal := <-osSignals - if osSignal == syscall.SIGHUP { + reloadTag := false + select { + case osSignal := <-osSignals: + if osSignal == syscall.SIGHUP { + err = check() + if err != nil { + log.Error(E.Cause(err, "reload service")) + continue + } + reloadTag = true + } + case <-instance.ReloadChan(): err = check() if err != nil { log.Error(E.Cause(err, "reload service")) continue } + reloadTag = true } cancel() closeCtx, closed := context.WithCancel(context.Background()) go closeMonitor(closeCtx) err = instance.Close() closed() - if osSignal != syscall.SIGHUP { + if !reloadTag { if err != nil { log.Error(E.Cause(err, "sing-box did not closed properly")) } diff --git a/experimental/clashapi/configs.go b/experimental/clashapi/configs.go index 33ad093fa3..a87e380ce8 100644 --- a/experimental/clashapi/configs.go +++ b/experimental/clashapi/configs.go @@ -12,7 +12,8 @@ import ( func configRouter(server *Server, logFactory log.Factory) http.Handler { r := chi.NewRouter() r.Get("/", getConfigs(server, logFactory)) - r.Put("/", updateConfigs) + // r.Put("/", updateConfigs) + r.Put("/", reload(server)) r.Patch("/", patchConfigs(server)) return r } diff --git a/experimental/clashapi/reload.go b/experimental/clashapi/reload.go new file mode 100644 index 0000000000..10c24b400e --- /dev/null +++ b/experimental/clashapi/reload.go @@ -0,0 +1,19 @@ +//go:build !ios + +package clashapi + +import ( + "net/http" + + "github.com/go-chi/render" +) + +func reload(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + server.logger.Warn("sing-box restarting...") + server.router.Reload() + }() + render.NoContent(w, r) + } +} diff --git a/experimental/clashapi/reload_stub.go b/experimental/clashapi/reload_stub.go new file mode 100644 index 0000000000..4326f8d236 --- /dev/null +++ b/experimental/clashapi/reload_stub.go @@ -0,0 +1,19 @@ +//go:build ios + +package clashapi + +import ( + "net/http" + + "github.com/go-chi/render" +) + +var ErrOSNotSupported = &HTTPError{ + Message: "OS not supported", +} + +func reload(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, ErrOSNotSupported) + } +} diff --git a/route/router.go b/route/router.go index 3be873913a..d74ebb4e0d 100644 --- a/route/router.go +++ b/route/router.go @@ -43,9 +43,10 @@ type Router struct { started bool defaultDomainMatchStrategy C.DomainMatchStrategy + reloadChan chan<- struct{} } -func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions) *Router { +func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions, reloadChan chan<- struct{}) *Router { return &Router{ ctx: ctx, logger: logFactory.NewLogger("router"), @@ -64,6 +65,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route platformInterface: service.FromContext[adapter.PlatformInterface](ctx), defaultDomainMatchStrategy: C.DomainMatchStrategy(options.DefaultDomainMatchStrategy), + reloadChan: reloadChan, } } @@ -272,3 +274,12 @@ func (r *Router) ResetNetwork() { func (r *Router) DefaultDomainMatchStrategy() C.DomainMatchStrategy { return r.defaultDomainMatchStrategy } + +func (r *Router) Reload() { + if r.platformInterface == nil { + select { + case r.reloadChan <- struct{}{}: + default: + } + } +} From 6bb68a9f064522b1ad94cb03444d24cd1e7d73dd Mon Sep 17 00:00:00 2001 From: yaotthaha Date: Thu, 13 Apr 2023 16:09:25 +0800 Subject: [PATCH 35/57] Add URLTest Fallback Support --- README.md | 28 +++++++++++++++++++++++++++ option/group.go | 16 +++++++++++----- protocol/group/urltest.go | 40 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index afc0fd3778..d9a8d7a3c9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,34 @@ The universal proxy platform. https://sing-box.sagernet.org +## URLTest Fallback 支持 + +按照**可用性**和**顺序**选择出站 + +可用:指 URL 测试存在有效结果 + +配置示例: +``` +{ + "tag": "fallback", + "type": "urltest", + "outbounds": [ + "A", + "B", + "C" + ], + "fallback": { + "enabled": true, // 开启 fallback + "max_delay": "200ms" // 可选配置 + // 若某节点可用,但是延迟超过 max_delay,则认为该节点不可用,淘汰忽略该节点,继续匹配选择下一个节点 + // 但若所有节点均不可用,但是存在被 max_delay 规则淘汰的节点,则选择延迟最低的被淘汰节点 + } +} +``` +以上配置为例子: +1. 当 A, B, C 都可用时,优选选择 A。当 A 不可用时,优选选择 B。当 A, B 都不可用时,选择 C,若 C 也不可用,则返回第一个出站:A +2. (配置了 max_delay) 当 A, C 都不可用,B 延迟超过 200ms 时(在第一轮选择时淘汰,被认为是不可用节点),则选择 B + For extended features - Providers: [中文](./docs/configuration/provider/index.zh.md), [English](./docs/configuration/provider/index.md) diff --git a/option/group.go b/option/group.go index 6aa6a1d34c..df7aea556c 100644 --- a/option/group.go +++ b/option/group.go @@ -10,11 +10,12 @@ type SelectorOutboundOptions struct { type URLTestOutboundOptions struct { GroupCommonOption - URL string `json:"url,omitempty"` - Interval badoption.Duration `json:"interval,omitempty"` - Tolerance uint16 `json:"tolerance,omitempty"` - IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Tolerance uint16 `json:"tolerance,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + Fallback URLTestFallbackOptions `json:"fallback,omitempty"` } type GroupCommonOption struct { @@ -24,3 +25,8 @@ type GroupCommonOption struct { Include *badoption.Regexp `json:"include,omitempty"` UseAllProviders bool `json:"use_all_providers,omitempty"` } + +type URLTestFallbackOptions struct { + Enabled bool `json:"enabled,omitempty"` + MaxDelay badoption.Duration `json:"max_delay,omitempty"` +} diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 376fa87cc3..2e5063a7fa 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -44,6 +44,7 @@ type URLTest struct { interval time.Duration tolerance uint16 idleTimeout time.Duration + fallback URLTestFallback group *URLTestGroup interruptExternalConnections bool @@ -58,6 +59,11 @@ type URLTest struct { useAllProviders bool } +type URLTestFallback struct { + enabled bool + maxDelay uint16 +} + func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { outbound := &URLTest{ Adapter: outbound.NewAdapter(C.TypeURLTest, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), @@ -82,6 +88,12 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo include: (*regexp.Regexp)(options.Include), useAllProviders: options.UseAllProviders, } + if options.Fallback.Enabled { + outbound.fallback = URLTestFallback{ + enabled: true, + maxDelay: uint16(time.Duration(options.Fallback.MaxDelay).Milliseconds()), + } + } return outbound, nil } @@ -121,7 +133,7 @@ func (s *URLTest) Start() error { s.tags = append(s.tags, detour.Tag()) outbounds = append(outbounds, detour) } - group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) + group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.fallback, s.interruptExternalConnections) if err != nil { return err } @@ -319,9 +331,11 @@ type URLTestGroup struct { close chan struct{} started bool lastActive common.TypedValue[time.Time] + + fallback URLTestFallback } -func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool) (*URLTestGroup, error) { +func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, fallback URLTestFallback, interruptExternalConnections bool) (*URLTestGroup, error) { if interval == 0 { interval = C.DefaultURLTestInterval } @@ -352,6 +366,7 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage tolerance: tolerance, idleTimeout: idleTimeout, history: history, + fallback: fallback, close: make(chan struct{}), pause: service.FromContext[pause.Manager](ctx), interruptGroup: interrupt.NewGroup(), @@ -397,6 +412,8 @@ func (g *URLTestGroup) Close() error { func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { var minDelay uint16 var minOutbound adapter.Outbound + var fallbackIgnoreOutboundDelay uint16 + var fallbackIgnoreOutbound adapter.Outbound switch network { case N.NetworkTCP: if g.selectedOutboundTCP != nil { @@ -421,11 +438,30 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { if history == nil { continue } + if g.fallback.enabled && g.fallback.maxDelay > 0 && history.Delay > g.fallback.maxDelay { + if fallbackIgnoreOutboundDelay == 0 || history.Delay < fallbackIgnoreOutboundDelay { + fallbackIgnoreOutboundDelay = history.Delay + fallbackIgnoreOutbound = detour + } + continue + } + if g.fallback.enabled { + minDelay = history.Delay + minOutbound = detour + if minDelay == 0 { + continue + } else { + break + } + } if minDelay == 0 || minDelay > history.Delay+g.tolerance { minDelay = history.Delay minOutbound = detour } } + if minOutbound == nil && fallbackIgnoreOutbound != nil { + return fallbackIgnoreOutbound, true + } if minOutbound == nil { for _, detour := range g.outbounds { if !common.Contains(detour.Network(), network) { From 31af41b68e20acabefaf6bc34a88558cdce2951b Mon Sep 17 00:00:00 2001 From: PuerNya Date: Tue, 13 Aug 2024 05:56:16 +0800 Subject: [PATCH 36/57] use static file instead of cache file --- docs/configuration/rule-set/index.md | 3 +- option/rule_set.go | 15 +- route/rule/rule_set_abstract.go | 165 +++++++++++++++++++++ route/rule/rule_set_local.go | 165 ++++----------------- route/rule/rule_set_remote.go | 208 +++++++-------------------- 5 files changed, 250 insertions(+), 306 deletions(-) create mode 100644 route/rule/rule_set_abstract.go diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index 73ec7b859f..daae56e564 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -42,6 +42,7 @@ "type": "remote", "tag": "", "format": "source", // or binary + "path": "", "url": "", "download_detour": "", // optional "update_interval": "" // optional @@ -82,8 +83,6 @@ Format of rule-set file, `source` or `binary`. Optional when `path` or `url` uses `json` or `srs` as extension. -### Local Fields - #### path ==Required== diff --git a/option/rule_set.go b/option/rule_set.go index 5db3fe14a3..49cdadcaa7 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -21,8 +21,8 @@ type _RuleSet struct { Type string `json:"type,omitempty"` Tag string `json:"tag"` Format string `json:"format,omitempty"` + Path string `json:"path,omitempty"` InlineOptions PlainRuleSet `json:"-"` - LocalOptions LocalRuleSet `json:"-"` RemoteOptions RemoteRuleSet `json:"-"` } @@ -33,7 +33,7 @@ func (r RuleSet) MarshalJSON() ([]byte, error) { var defaultFormat string switch r.Type { case C.RuleSetTypeLocal: - defaultFormat = ruleSetDefaultFormat(r.LocalOptions.Path) + defaultFormat = ruleSetDefaultFormat(r.Path) case C.RuleSetTypeRemote: defaultFormat = ruleSetDefaultFormat(r.RemoteOptions.URL) } @@ -47,7 +47,7 @@ func (r RuleSet) MarshalJSON() ([]byte, error) { r.Type = "" v = r.InlineOptions case C.RuleSetTypeLocal: - v = r.LocalOptions + v = nil case C.RuleSetTypeRemote: v = r.RemoteOptions default: @@ -70,7 +70,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { r.Type = C.RuleSetTypeInline v = &r.InlineOptions case C.RuleSetTypeLocal: - v = &r.LocalOptions + v = nil case C.RuleSetTypeRemote: v = &r.RemoteOptions default: @@ -84,7 +84,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { if r.Format == "" { switch r.Type { case C.RuleSetTypeLocal: - r.Format = ruleSetDefaultFormat(r.LocalOptions.Path) + r.Format = ruleSetDefaultFormat(r.Path) case C.RuleSetTypeRemote: r.Format = ruleSetDefaultFormat(r.RemoteOptions.URL) } @@ -98,6 +98,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { } } else { r.Format = "" + r.Path = "" } return nil } @@ -116,10 +117,6 @@ func ruleSetDefaultFormat(path string) string { } } -type LocalRuleSet struct { - Path string `json:"path,omitempty"` -} - type RemoteRuleSet struct { URL string `json:"url"` DownloadDetour string `json:"download_detour,omitempty"` diff --git a/route/rule/rule_set_abstract.go b/route/rule/rule_set_abstract.go new file mode 100644 index 0000000000..a3192a1d02 --- /dev/null +++ b/route/rule/rule_set_abstract.go @@ -0,0 +1,165 @@ +package rule + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/rw" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service/filemanager" + + "go4.org/netipx" +) + +type abstractRuleSet struct { + ctx context.Context + logger logger.ContextLogger + tag string + access sync.RWMutex + path string + format string + rules []adapter.HeadlessRule + metadata adapter.RuleSetMetadata + lastUpdated time.Time + callbacks list.List[adapter.RuleSetUpdateCallback] + refs atomic.Int32 +} + +func (s *abstractRuleSet) Name() string { + return s.tag +} + +func (s *abstractRuleSet) String() string { + return strings.Join(F.MapToString(s.rules), " ") +} + +func (s *abstractRuleSet) getPath(ctx context.Context, path string) (string, error) { + if path == "" { + path = s.tag + switch s.format { + case C.RuleSetFormatSource, "": + path += ".json" + case C.RuleSetFormatBinary: + path += ".srs" + } + } + path = filemanager.BasePath(ctx, path) + path, _ = filepath.Abs(path) + if rw.IsDir(path) { + return "", E.New("rule_set path is a directory: ", path) + } + return path, nil +} + +func (s *abstractRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.RLock() + defer s.access.RUnlock() + return s.metadata +} + +func (s *abstractRuleSet) ExtractIPSet() []*netipx.IPSet { + s.access.RLock() + defer s.access.RUnlock() + return common.FlatMap(s.rules, extractIPSetFromRule) +} + +func (s *abstractRuleSet) IncRef() { + s.refs.Add(1) +} + +func (s *abstractRuleSet) DecRef() { + if s.refs.Add(-1) < 0 { + panic("rule-set: negative refs") + } +} + +func (s *abstractRuleSet) Cleanup() { + if s.refs.Load() == 0 { + s.rules = nil + } +} + +func (s *abstractRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.PushBack(callback) +} + +func (s *abstractRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { + s.access.Lock() + defer s.access.Unlock() + s.callbacks.Remove(element) +} + +func (s *abstractRuleSet) loadBytes(content []byte, ruleset adapter.RuleSet) error { + var ( + ruleSet option.PlainRuleSetCompat + err error + ) + switch s.format { + case C.RuleSetFormatSource: + ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + case C.RuleSetFormatBinary: + ruleSet, err = srs.Read(bytes.NewReader(content), false) + if err != nil { + return err + } + default: + return E.New("unknown rule-set format: ", s.format) + } + plainRuleSet, err := ruleSet.Upgrade() + if err != nil { + return err + } + return s.reloadRules(plainRuleSet.Rules, ruleset) +} + +func (s *abstractRuleSet) reloadRules(headlessRules []option.HeadlessRule, ruleSet adapter.RuleSet) error { + rules := make([]adapter.HeadlessRule, len(headlessRules)) + var err error + for i, ruleOptions := range headlessRules { + rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) + if err != nil { + return E.Cause(err, "parse rule_set.rules.[", i, "]") + } + } + var metadata adapter.RuleSetMetadata + metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) + metadata.ContainsWIFIRule = HasHeadlessRule(headlessRules, isWIFIHeadlessRule) + metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) + s.access.Lock() + s.rules = rules + s.metadata = metadata + callbacks := s.callbacks.Array() + s.access.Unlock() + for _, callback := range callbacks { + callback(ruleSet) + } + return nil +} + +func (s *abstractRuleSet) Match(metadata *adapter.InboundContext) bool { + for _, rule := range s.rules { + if rule.Match(metadata) { + return true + } + } + return false +} diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index b09915ed2f..dad3984a9e 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -2,71 +2,59 @@ package rule import ( "context" + "io" "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" "github.com/sagernet/fswatch" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/srs" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" - "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/logger" - "github.com/sagernet/sing/common/x/list" - "github.com/sagernet/sing/service/filemanager" - - "go4.org/netipx" ) var _ adapter.RuleSet = (*LocalRuleSet)(nil) type LocalRuleSet struct { - ctx context.Context - logger logger.Logger - tag string - access sync.RWMutex - rules []adapter.HeadlessRule - metadata adapter.RuleSetMetadata - fileFormat string - watcher *fswatch.Watcher - callbacks list.List[adapter.RuleSetUpdateCallback] - refs atomic.Int32 + abstractRuleSet + watcher *fswatch.Watcher } -func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) { +func NewLocalRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (*LocalRuleSet, error) { ruleSet := &LocalRuleSet{ - ctx: ctx, - logger: logger, - tag: options.Tag, - fileFormat: options.Format, + abstractRuleSet: abstractRuleSet{ + ctx: ctx, + logger: logger, + tag: options.Tag, + }, } if options.Type == C.RuleSetTypeInline { if len(options.InlineOptions.Rules) == 0 { return nil, E.New("empty inline rule-set") } - err := ruleSet.reloadRules(options.InlineOptions.Rules) + err := ruleSet.reloadRules(options.InlineOptions.Rules, ruleSet) if err != nil { return nil, err } } else { - filePath := filemanager.BasePath(ctx, options.LocalOptions.Path) - filePath, _ = filepath.Abs(filePath) - err := ruleSet.reloadFile(filePath) + ruleSet.format = options.Format + path, err := ruleSet.getPath(ctx, options.Path) + if err != nil { + return nil, err + } + ruleSet.path = path + err = ruleSet.reloadFile(path) if err != nil { return nil, err } watcher, err := fswatch.NewWatcher(fswatch.Options{ - Path: []string{filePath}, + Path: []string{path}, Callback: func(path string) { uErr := ruleSet.reloadFile(path) if uErr != nil { - logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag)) + logger.ErrorContext(log.ContextWithNewID(context.Background()), E.Cause(uErr, "reload rule-set ", options.Tag)) } }, }) @@ -78,14 +66,6 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R return ruleSet, nil } -func (s *LocalRuleSet) Name() string { - return s.tag -} - -func (s *LocalRuleSet) String() string { - return strings.Join(F.MapToString(s.rules), " ") -} - func (s *LocalRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { if s.watcher != nil { err := s.watcher.Start() @@ -97,58 +77,20 @@ func (s *LocalRuleSet) StartContext(ctx context.Context, startContext *adapter.H } func (s *LocalRuleSet) reloadFile(path string) error { - var ruleSet option.PlainRuleSetCompat - switch s.fileFormat { - case C.RuleSetFormatSource, "": - content, err := os.ReadFile(path) - if err != nil { - return err - } - ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) - if err != nil { - return err - } - - case C.RuleSetFormatBinary: - setFile, err := os.Open(path) - if err != nil { - return err - } - ruleSet, err = srs.Read(setFile, false) - if err != nil { - return err - } - default: - return E.New("unknown rule-set format: ", s.fileFormat) - } - plainRuleSet, err := ruleSet.Upgrade() + file, err := os.Open(path) if err != nil { return err } - return s.reloadRules(plainRuleSet.Rules) -} - -func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error { - rules := make([]adapter.HeadlessRule, len(headlessRules)) - var err error - for i, ruleOptions := range headlessRules { - rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) - if err != nil { - return E.Cause(err, "parse rule_set.rules.[", i, "]") - } + content, err := io.ReadAll(file) + if err != nil { + return err } - var metadata adapter.RuleSetMetadata - metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) - metadata.ContainsWIFIRule = HasHeadlessRule(headlessRules, isWIFIHeadlessRule) - metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) - s.access.Lock() - s.rules = rules - s.metadata = metadata - callbacks := s.callbacks.Array() - s.access.Unlock() - for _, callback := range callbacks { - callback(s) + err = s.loadBytes(content, s) + if err != nil { + return err } + fs, _ := file.Stat() + s.lastUpdated = fs.ModTime() return nil } @@ -156,56 +98,7 @@ func (s *LocalRuleSet) PostStart() error { return nil } -func (s *LocalRuleSet) Metadata() adapter.RuleSetMetadata { - s.access.RLock() - defer s.access.RUnlock() - return s.metadata -} - -func (s *LocalRuleSet) ExtractIPSet() []*netipx.IPSet { - s.access.RLock() - defer s.access.RUnlock() - return common.FlatMap(s.rules, extractIPSetFromRule) -} - -func (s *LocalRuleSet) IncRef() { - s.refs.Add(1) -} - -func (s *LocalRuleSet) DecRef() { - if s.refs.Add(-1) < 0 { - panic("rule-set: negative refs") - } -} - -func (s *LocalRuleSet) Cleanup() { - if s.refs.Load() == 0 { - s.rules = nil - } -} - -func (s *LocalRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { - s.access.Lock() - defer s.access.Unlock() - return s.callbacks.PushBack(callback) -} - -func (s *LocalRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { - s.access.Lock() - defer s.access.Unlock() - s.callbacks.Remove(element) -} - func (s *LocalRuleSet) Close() error { s.rules = nil return common.Close(common.PtrOrNil(s.watcher)) } - -func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { - for _, rule := range s.rules { - if rule.Match(metadata) { - return true - } - } - return false -} diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 3aba76bab6..adeaeebbe2 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -1,57 +1,45 @@ package rule import ( - "bytes" "context" "crypto/tls" "io" "net" "net/http" + "os" + "path/filepath" "runtime" "strings" - "sync" - "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/srs" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" - "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" "github.com/sagernet/sing/service/pause" - - "go4.org/netipx" ) var _ adapter.RuleSet = (*RemoteRuleSet)(nil) type RemoteRuleSet struct { - ctx context.Context + abstractRuleSet cancel context.CancelFunc - logger logger.ContextLogger outbound adapter.OutboundManager - options option.RuleSet + options option.RemoteRuleSet updateInterval time.Duration dialer N.Dialer - access sync.RWMutex - rules []adapter.HeadlessRule - metadata adapter.RuleSetMetadata - lastUpdated time.Time lastEtag string updateTicker *time.Ticker cacheFile adapter.CacheFile pauseManager pause.Manager - callbacks list.List[adapter.RuleSetUpdateCallback] - refs atomic.Int32 } func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet { @@ -63,20 +51,21 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options updateInterval = 24 * time.Hour } return &RemoteRuleSet{ - ctx: ctx, - cancel: cancel, + abstractRuleSet: abstractRuleSet{ + ctx: ctx, + logger: logger, + tag: options.Tag, + path: options.Path, + format: options.Format, + }, outbound: service.FromContext[adapter.OutboundManager](ctx), - logger: logger, - options: options, + cancel: cancel, + options: options.RemoteOptions, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), } } -func (s *RemoteRuleSet) Name() string { - return s.options.Tag -} - func (s *RemoteRuleSet) String() string { return strings.Join(F.MapToString(s.rules), " ") } @@ -84,30 +73,24 @@ func (s *RemoteRuleSet) String() string { func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) var dialer N.Dialer - if s.options.RemoteOptions.DownloadDetour != "" { - outbound, loaded := s.outbound.Outbound(s.options.RemoteOptions.DownloadDetour) + if s.options.DownloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.options.DownloadDetour) if !loaded { - return E.New("download detour not found: ", s.options.RemoteOptions.DownloadDetour) + return E.New("download detour not found: ", s.options.DownloadDetour) } dialer = outbound } else { dialer = s.outbound.Default() } s.dialer = dialer - if s.cacheFile != nil { - if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { - err := s.loadBytes(savedSet.Content) - if err != nil { - return E.Cause(err, "restore cached rule-set") - } - s.lastUpdated = savedSet.LastUpdated - s.lastEtag = savedSet.LastEtag - } + if path, err := s.getPath(ctx, s.path); err == nil { + s.path = path + s.loadFromFile(path) } if s.lastUpdated.IsZero() { err := s.fetch(ctx, startContext) if err != nil { - return E.Cause(err, "initial rule-set: ", s.options.Tag) + return E.Cause(err, "initial rule-set: ", s.tag) } } s.updateTicker = time.NewTicker(s.updateInterval) @@ -119,97 +102,27 @@ func (s *RemoteRuleSet) PostStart() error { return nil } -func (s *RemoteRuleSet) Metadata() adapter.RuleSetMetadata { - s.access.RLock() - defer s.access.RUnlock() - return s.metadata -} - -func (s *RemoteRuleSet) ExtractIPSet() []*netipx.IPSet { - s.access.RLock() - defer s.access.RUnlock() - return common.FlatMap(s.rules, extractIPSetFromRule) -} - -func (s *RemoteRuleSet) IncRef() { - s.refs.Add(1) -} - -func (s *RemoteRuleSet) DecRef() { - if s.refs.Add(-1) < 0 { - panic("rule-set: negative refs") - } -} - -func (s *RemoteRuleSet) Cleanup() { - if s.refs.Load() == 0 { - s.rules = nil - } -} - -func (s *RemoteRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { - s.access.Lock() - defer s.access.Unlock() - return s.callbacks.PushBack(callback) -} - -func (s *RemoteRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { - s.access.Lock() - defer s.access.Unlock() - s.callbacks.Remove(element) -} - -func (s *RemoteRuleSet) loadBytes(content []byte) error { - var ( - ruleSet option.PlainRuleSetCompat - err error - ) - switch s.options.Format { - case C.RuleSetFormatSource: - ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) - if err != nil { - return err - } - case C.RuleSetFormatBinary: - ruleSet, err = srs.Read(bytes.NewReader(content), false) - if err != nil { - return err - } - default: - return E.New("unknown rule-set format: ", s.options.Format) - } - plainRuleSet, err := ruleSet.Upgrade() +func (s *RemoteRuleSet) loadFromFile(path string) error { + file, err := os.Open(path) if err != nil { return err } - rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules)) - for i, ruleOptions := range plainRuleSet.Rules { - rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) - if err != nil { - return E.Cause(err, "parse rule_set.rules.[", i, "]") - } + content, err := io.ReadAll(file) + if err != nil { + return err } - s.access.Lock() - s.metadata.ContainsProcessRule = HasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule) - s.metadata.ContainsWIFIRule = HasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule) - s.metadata.ContainsIPCIDRRule = HasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule) - s.rules = rules - callbacks := s.callbacks.Array() - s.access.Unlock() - for _, callback := range callbacks { - callback(s) + err = s.loadBytes(content, s) + if err != nil { + return err } + fs, _ := file.Stat() + s.lastUpdated = fs.ModTime() return nil } func (s *RemoteRuleSet) loopUpdate() { if time.Since(s.lastUpdated) > s.updateInterval { - err := s.fetch(s.ctx, nil) - if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) - } else if s.refs.Load() == 0 { - s.rules = nil - } + s.update() } for { runtime.GC() @@ -217,25 +130,26 @@ func (s *RemoteRuleSet) loopUpdate() { case <-s.ctx.Done(): return case <-s.updateTicker.C: - s.updateOnce() + s.update() } } } -func (s *RemoteRuleSet) updateOnce() { - err := s.fetch(s.ctx, nil) +func (s *RemoteRuleSet) update() { + ctx := log.ContextWithNewID(s.ctx) + err := s.fetch(ctx, nil) if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + s.logger.ErrorContext(ctx, "fetch rule-set ", s.tag, ": ", err) } else if s.refs.Load() == 0 { s.rules = nil } } func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPStartContext) error { - s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) + s.logger.DebugContext(ctx, "updating rule-set ", s.tag, " from URL: ", s.options.URL) var httpClient *http.Client if startContext != nil { - httpClient = startContext.HTTPClient(s.options.RemoteOptions.DownloadDetour, s.dialer) + httpClient = startContext.HTTPClient(s.options.DownloadDetour, s.dialer) } else { httpClient = &http.Client{ Transport: &http.Transport{ @@ -251,7 +165,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta }, } } - request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) + request, err := http.NewRequest("GET", s.options.URL, nil) if err != nil { return err } @@ -266,18 +180,8 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta case http.StatusOK: case http.StatusNotModified: s.lastUpdated = time.Now() - if s.cacheFile != nil { - savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) - if savedRuleSet != nil { - savedRuleSet.LastUpdated = s.lastUpdated - err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) - if err != nil { - s.logger.Error("save rule-set updated time: ", err) - return nil - } - } - } - s.logger.Info("update rule-set ", s.options.Tag, ": not modified") + os.Chtimes(s.path, s.lastUpdated, s.lastUpdated) + s.logger.InfoContext(ctx, "update rule-set ", s.tag, ": not modified") return nil default: return E.New("unexpected status: ", response.Status) @@ -287,7 +191,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta response.Body.Close() return err } - err = s.loadBytes(content) + err = s.loadBytes(content, s) if err != nil { response.Body.Close() return err @@ -298,17 +202,12 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta s.lastEtag = eTagHeader } s.lastUpdated = time.Now() - if s.cacheFile != nil { - err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{ - LastUpdated: s.lastUpdated, - Content: content, - LastEtag: s.lastEtag, - }) - if err != nil { - s.logger.Error("save rule-set cache: ", err) - } + dir := filepath.Dir(s.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + filemanager.MkdirAll(ctx, dir, 0o755) } - s.logger.Info("updated rule-set ", s.options.Tag) + filemanager.WriteFile(ctx, s.path, []byte(content), 0o666) + s.logger.InfoContext(ctx, "updated rule-set ", s.tag) return nil } @@ -320,12 +219,3 @@ func (s *RemoteRuleSet) Close() error { } return nil } - -func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { - for _, rule := range s.rules { - if rule.Match(metadata) { - return true - } - } - return false -} From de7db05744d6ec64ff12a0d6652b75a2a1e2b61e Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 18 Apr 2025 19:59:02 +0800 Subject: [PATCH 37/57] makes remote rule-set `path` correct --- route/rule/rule_set_abstract.go | 21 ----- route/rule/rule_set_local.go | 21 +++++ route/rule/rule_set_remote.go | 137 +++++++++++++++++++++++++------- 3 files changed, 131 insertions(+), 48 deletions(-) diff --git a/route/rule/rule_set_abstract.go b/route/rule/rule_set_abstract.go index a3192a1d02..285da01ffa 100644 --- a/route/rule/rule_set_abstract.go +++ b/route/rule/rule_set_abstract.go @@ -3,7 +3,6 @@ package rule import ( "bytes" "context" - "path/filepath" "strings" "sync" "sync/atomic" @@ -18,9 +17,7 @@ import ( F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/logger" - "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/common/x/list" - "github.com/sagernet/sing/service/filemanager" "go4.org/netipx" ) @@ -47,24 +44,6 @@ func (s *abstractRuleSet) String() string { return strings.Join(F.MapToString(s.rules), " ") } -func (s *abstractRuleSet) getPath(ctx context.Context, path string) (string, error) { - if path == "" { - path = s.tag - switch s.format { - case C.RuleSetFormatSource, "": - path += ".json" - case C.RuleSetFormatBinary: - path += ".srs" - } - } - path = filemanager.BasePath(ctx, path) - path, _ = filepath.Abs(path) - if rw.IsDir(path) { - return "", E.New("rule_set path is a directory: ", path) - } - return path, nil -} - func (s *abstractRuleSet) Metadata() adapter.RuleSetMetadata { s.access.RLock() defer s.access.RUnlock() diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index dad3984a9e..e9bab2bbfc 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -4,6 +4,7 @@ import ( "context" "io" "os" + "path/filepath" "github.com/sagernet/fswatch" "github.com/sagernet/sing-box/adapter" @@ -13,6 +14,8 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/rw" + "github.com/sagernet/sing/service/filemanager" ) var _ adapter.RuleSet = (*LocalRuleSet)(nil) @@ -94,6 +97,24 @@ func (s *LocalRuleSet) reloadFile(path string) error { return nil } +func (s *LocalRuleSet) getPath(ctx context.Context, path string) (string, error) { + if path == "" { + path = s.tag + switch s.format { + case C.RuleSetFormatSource, "": + path += ".json" + case C.RuleSetFormatBinary: + path += ".srs" + } + } + path = filemanager.BasePath(ctx, path) + path, _ = filepath.Abs(path) + if rw.IsDir(path) { + return "", E.New("rule_set path is a directory: ", path) + } + return path, nil +} + func (s *LocalRuleSet) PostStart() error { return nil } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index adeaeebbe2..dd25255690 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/hash" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -22,6 +23,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/filemanager" "github.com/sagernet/sing/service/pause" @@ -36,6 +38,7 @@ type RemoteRuleSet struct { options option.RemoteRuleSet updateInterval time.Duration dialer N.Dialer + hash hash.HashType lastEtag string updateTicker *time.Ticker cacheFile adapter.CacheFile @@ -44,6 +47,11 @@ type RemoteRuleSet struct { func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet { ctx, cancel := context.WithCancel(ctx) + var path string + if options.Path != "" { + path = filemanager.BasePath(ctx, options.Path) + path, _ = filepath.Abs(path) + } var updateInterval time.Duration if options.RemoteOptions.UpdateInterval > 0 { updateInterval = time.Duration(options.RemoteOptions.UpdateInterval) @@ -55,7 +63,7 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options ctx: ctx, logger: logger, tag: options.Tag, - path: options.Path, + path: path, format: options.Format, }, outbound: service.FromContext[adapter.OutboundManager](ctx), @@ -83,9 +91,9 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. dialer = s.outbound.Default() } s.dialer = dialer - if path, err := s.getPath(ctx, s.path); err == nil { - s.path = path - s.loadFromFile(path) + err := s.loadCacheFile() + if err != nil { + return E.Cause(err, "restore cached rule-set") } if s.lastUpdated.IsZero() { err := s.fetch(ctx, startContext) @@ -102,24 +110,6 @@ func (s *RemoteRuleSet) PostStart() error { return nil } -func (s *RemoteRuleSet) loadFromFile(path string) error { - file, err := os.Open(path) - if err != nil { - return err - } - content, err := io.ReadAll(file) - if err != nil { - return err - } - err = s.loadBytes(content, s) - if err != nil { - return err - } - fs, _ := file.Stat() - s.lastUpdated = fs.ModTime() - return nil -} - func (s *RemoteRuleSet) loopUpdate() { if time.Since(s.lastUpdated) > s.updateInterval { s.update() @@ -180,7 +170,17 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta case http.StatusOK: case http.StatusNotModified: s.lastUpdated = time.Now() - os.Chtimes(s.path, s.lastUpdated, s.lastUpdated) + if s.path != "" { + os.Chtimes(s.path, s.lastUpdated, s.lastUpdated) + } + if s.cacheFile != nil { + if savedRuleSet := s.cacheFile.LoadRuleSet(s.tag); savedRuleSet != nil { + savedRuleSet.LastUpdated = s.lastUpdated + if err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet); err != nil { + s.logger.Error("save rule-set updated time: ", err) + } + } + } s.logger.InfoContext(ctx, "update rule-set ", s.tag, ": not modified") return nil default: @@ -202,15 +202,98 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta s.lastEtag = eTagHeader } s.lastUpdated = time.Now() - dir := filepath.Dir(s.path) - if _, err := os.Stat(dir); os.IsNotExist(err) { - filemanager.MkdirAll(ctx, dir, 0o755) + if s.path != "" { + s.saveCacheFile(content) + } + if s.cacheFile != nil { + savedRuleSet := &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + LastEtag: s.lastEtag, + } + if s.path != "" { + savedRuleSet.Hash = s.hash + } else { + savedRuleSet.Content = content + } + if err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet); err != nil { + s.logger.Error("save rule-set cache: ", err) + } } - filemanager.WriteFile(ctx, s.path, []byte(content), 0o666) s.logger.InfoContext(ctx, "updated rule-set ", s.tag) return nil } +func (s *RemoteRuleSet) loadCacheFile() error { + var content []byte + var lastUpdated time.Time + var lastEtag string + var savedSet *adapter.SavedBinary + if s.cacheFile != nil { + if savedSet = s.cacheFile.LoadRuleSet(s.tag); savedSet != nil { + s.hash = savedSet.Hash + } + } + if s.path != "" { + exists, err := pathExists(s.path) + if err != nil { + return err + } + if !exists { + return nil + } + file, _ := os.Open(s.path) + content, err = io.ReadAll(file) + if err != nil { + return err + } + if savedSet != nil { + if !s.hash.Equal(hash.MakeHash(content)) { + s.logger.Error("load rule-set cache file failed: validation failed") + return nil + } + lastUpdated = savedSet.LastUpdated + lastEtag = savedSet.LastEtag + } else { + fs, _ := file.Stat() + lastUpdated = fs.ModTime() + } + } else if savedSet != nil && savedSet.Content != nil { + content = savedSet.Content + lastUpdated = savedSet.LastUpdated + lastEtag = savedSet.LastEtag + } else { + return nil + } + if err := s.loadBytes(content, s); err != nil { + return err + } + s.lastUpdated, s.lastEtag = lastUpdated, lastEtag + return nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + if rw.IsDir(path) { + return false, E.New("rule_set path is a directory: ", path) + } + return false, err +} + +func (s *RemoteRuleSet) saveCacheFile(contentRaw []byte) { + s.hash = hash.MakeHash(contentRaw) + dir := filepath.Dir(s.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + filemanager.MkdirAll(s.ctx, dir, 0o755) + } + filemanager.WriteFile(s.ctx, s.path, []byte(contentRaw), 0o666) +} + func (s *RemoteRuleSet) Close() error { s.rules = nil s.cancel() From d90b9c97dc372c0cf2d8777a694c9e2c8d176b0f Mon Sep 17 00:00:00 2001 From: PuerNya Date: Tue, 13 Aug 2024 06:42:47 +0800 Subject: [PATCH 38/57] add rule-provider clash-api --- adapter/router.go | 5 ++ adapter/rule.go | 1 + experimental/clashapi/ruleprovider.go | 91 ++++++++++++++++++--------- experimental/clashapi/server.go | 2 +- option/rule_set.go | 2 +- route/router.go | 4 ++ route/rule/rule_abstract.go | 10 +++ route/rule/rule_abstract_test.go | 21 +++++++ route/rule/rule_headless.go | 13 ++++ route/rule/rule_set_abstract.go | 25 +++++++- route/rule/rule_set_local.go | 7 ++- route/rule/rule_set_remote.go | 10 +++ 12 files changed, 158 insertions(+), 33 deletions(-) diff --git a/adapter/router.go b/adapter/router.go index 1f05380a8a..5921b1f730 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -23,6 +23,7 @@ type Router interface { ConnectionRouter PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration, supportBypass bool) (tun.DirectRouteDestination, error) ConnectionRouterEx + RuleSets() []RuleSet RuleSet(tag string) (RuleSet, bool) Rules() []Rule NeedFindProcess() bool @@ -54,6 +55,10 @@ type ConnectionRouterEx interface { type RuleSet interface { Name() string + Type() string + Format() string + UpdatedTime() time.Time + Update(ctx context.Context) error StartContext(ctx context.Context, startContext *HTTPStartContext) error PostStart() error Metadata() RuleSetMetadata diff --git a/adapter/rule.go b/adapter/rule.go index f8ee797d44..7c8aceb59f 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -6,6 +6,7 @@ import ( type HeadlessRule interface { Match(metadata *InboundContext) bool + RuleCount() uint64 String() string } diff --git a/experimental/clashapi/ruleprovider.go b/experimental/clashapi/ruleprovider.go index 4a410854a5..f618b19d46 100644 --- a/experimental/clashapi/ruleprovider.go +++ b/experimental/clashapi/ruleprovider.go @@ -1,58 +1,93 @@ package clashapi import ( + "context" "net/http" + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badjson" "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func ruleProviderRouter() http.Handler { +func ruleProviderRouter(router adapter.Router) http.Handler { r := chi.NewRouter() - r.Get("/", getRuleProviders) + r.Get("/", getRuleProviders(router)) r.Route("/{name}", func(r chi.Router) { - r.Use(parseProviderName, findRuleProviderByName) + r.Use(parseProviderName, findRuleProviderByName(router)) r.Get("/", getRuleProvider) r.Put("/", updateRuleProvider) }) return r } -func getRuleProviders(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{ - "providers": []string{}, - }) +func ruleSetInfo(ruleSet adapter.RuleSet) *badjson.JSONObject { + var info badjson.JSONObject + info.Put("name", ruleSet.Name()) + info.Put("type", "Rule") + info.Put("vehicleType", strings.ToUpper(ruleSet.Type())) + info.Put("behavior", strings.ToUpper(ruleSet.Format())) + info.Put("ruleCount", ruleSet.RuleCount()) + info.Put("updatedAt", ruleSet.UpdatedTime().Format("2006-01-02T15:04:05.999999999-07:00")) + return &info +} + +func getRuleProviders(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + providerMap := render.M{} + for i, ruleSet := range router.RuleSets() { + var tag string + if ruleSet.Name() == "" { + tag = F.ToString(i) + } else { + tag = ruleSet.Name() + } + providerMap[tag] = ruleSetInfo(ruleSet) + } + render.JSON(w, r, render.M{ + "providers": providerMap, + }) + } } func getRuleProvider(w http.ResponseWriter, r *http.Request) { - // provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) - // render.JSON(w, r, provider) - render.NoContent(w, r) + ruleSet := r.Context().Value(CtxKeyProvider).(adapter.RuleSet) + response, err := ruleSetInfo(ruleSet).MarshalJSON() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + w.Write(response) } func updateRuleProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) - if err := provider.Update(); err != nil { - render.Status(r, http.StatusServiceUnavailable) + ruleSet := r.Context().Value(CtxKeyProvider).(adapter.RuleSet) + err := ruleSet.Update(r.Context()) + if err != nil { + render.Status(r, http.StatusInternalServerError) render.JSON(w, r, newError(err.Error())) return - }*/ + } render.NoContent(w, r) } -func findRuleProviderByName(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - /*name := r.Context().Value(CtxKeyProviderName).(string) - providers := tunnel.RuleProviders() - provider, exist := providers[name] - if !exist {*/ - render.Status(r, http.StatusNotFound) - render.JSON(w, r, ErrNotFound) - //return - //} - - // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) - // next.ServeHTTP(w, r.WithContext(ctx)) - }) +func findRuleProviderByName(router adapter.Router) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProviderName).(string) + provider, exist := router.RuleSet(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } + ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 5d71001a5a..e0d984f48a 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -139,7 +139,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Mount("/rules", ruleRouter(s.router, s.dnsRouter)) r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) r.Mount("/providers/proxies", proxyProviderRouter(s)) - r.Mount("/providers/rules", ruleProviderRouter()) + r.Mount("/providers/rules", ruleProviderRouter(s.router)) r.Mount("/script", scriptRouter()) r.Mount("/profile", profileRouter()) r.Mount("/cache", cacheRouter(ctx)) diff --git a/option/rule_set.go b/option/rule_set.go index 49cdadcaa7..b45b493c04 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -97,7 +97,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { return E.New("unknown rule-set format: " + r.Format) } } else { - r.Format = "" + r.Format = C.RuleSetFormatSource r.Path = "" } return nil diff --git a/route/router.go b/route/router.go index d74ebb4e0d..a4f09b807f 100644 --- a/route/router.go +++ b/route/router.go @@ -241,6 +241,10 @@ func (r *Router) Close() error { return err } +func (r *Router) RuleSets() []adapter.RuleSet { + return r.ruleSets +} + func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) { ruleSet, loaded := r.ruleSetMap[tag] return ruleSet, loaded diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index ac85f5f1bf..1831943bf3 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -20,6 +20,7 @@ type abstractDefaultRule struct { allItems []RuleItem ruleSetItem RuleItem domainMatchStrategy C.DomainMatchStrategy + ruleCount uint64 invert bool action adapter.RuleAction } @@ -28,6 +29,10 @@ func (r *abstractDefaultRule) Type() string { return C.RuleTypeDefault } +func (r *abstractDefaultRule) RuleCount() uint64 { + return r.ruleCount +} + func (r *abstractDefaultRule) Start() error { for _, item := range r.allItems { if starter, isStarter := item.(interface { @@ -155,12 +160,17 @@ type abstractLogicalRule struct { domainMatchStrategy C.DomainMatchStrategy invert bool action adapter.RuleAction + ruleCount uint64 } func (r *abstractLogicalRule) Type() string { return C.RuleTypeLogical } +func (r *abstractLogicalRule) RuleCount() uint64 { + return r.ruleCount +} + func (r *abstractLogicalRule) Start() error { for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (interface { Start() error diff --git a/route/rule/rule_abstract_test.go b/route/rule/rule_abstract_test.go index 2d2e8ba861..7b1f70e5ac 100644 --- a/route/rule/rule_abstract_test.go +++ b/route/rule/rule_abstract_test.go @@ -3,6 +3,7 @@ package rule import ( "context" "testing" + "time" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -20,6 +21,22 @@ func (f *fakeRuleSet) Name() string { return "fake-rule-set" } +func (f *fakeRuleSet) Type() string { + return "fake" +} + +func (f *fakeRuleSet) Format() string { + return "fake" +} + +func (f *fakeRuleSet) UpdatedTime() time.Time { + return time.Time{} +} + +func (f *fakeRuleSet) Update(context.Context) error { + return nil +} + func (f *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } @@ -56,6 +73,10 @@ func (f *fakeRuleSet) Match(*adapter.InboundContext) bool { return f.matched } +func (f *fakeRuleSet) RuleCount() uint64 { + return 1 +} + func (f *fakeRuleSet) String() string { return "fake-rule-set" } diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index 84a3ab6644..5d2a7a25c2 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -195,6 +195,18 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } + switch true { + case len(rule.allItems) == len(rule.destinationAddressItems)+len(rule.destinationIPCIDRItems): + rule.ruleCount = uint64(len(rule.destinationAddressItems) + len(rule.destinationIPCIDRItems)) + case len(rule.allItems) == len(rule.sourceAddressItems): + rule.ruleCount = uint64(len(rule.sourceAddressItems)) + case len(rule.allItems) == len(rule.sourcePortItems): + rule.ruleCount = uint64(len(rule.sourcePortItems)) + case len(rule.allItems) == len(rule.destinationPortItems): + rule.ruleCount = uint64(len(rule.destinationPortItems)) + default: + rule.ruleCount = 1 + } return rule, nil } @@ -231,5 +243,6 @@ func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessR } r.rules[i] = rule } + r.ruleCount = 1 return r, nil } diff --git a/route/rule/rule_set_abstract.go b/route/rule/rule_set_abstract.go index 285da01ffa..5000c8a0c2 100644 --- a/route/rule/rule_set_abstract.go +++ b/route/rule/rule_set_abstract.go @@ -27,9 +27,11 @@ type abstractRuleSet struct { logger logger.ContextLogger tag string access sync.RWMutex + sType string path string format string rules []adapter.HeadlessRule + ruleCount uint64 metadata adapter.RuleSetMetadata lastUpdated time.Time callbacks list.List[adapter.RuleSetUpdateCallback] @@ -40,6 +42,22 @@ func (s *abstractRuleSet) Name() string { return s.tag } +func (s *abstractRuleSet) Type() string { + return s.sType +} + +func (s *abstractRuleSet) Format() string { + return s.format +} + +func (s *abstractRuleSet) RuleCount() uint64 { + return s.ruleCount +} + +func (s *abstractRuleSet) UpdatedTime() time.Time { + return s.lastUpdated +} + func (s *abstractRuleSet) String() string { return strings.Join(F.MapToString(s.rules), " ") } @@ -112,12 +130,14 @@ func (s *abstractRuleSet) loadBytes(content []byte, ruleset adapter.RuleSet) err func (s *abstractRuleSet) reloadRules(headlessRules []option.HeadlessRule, ruleSet adapter.RuleSet) error { rules := make([]adapter.HeadlessRule, len(headlessRules)) - var err error + var ruleCount uint64 for i, ruleOptions := range headlessRules { - rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) + rule, err := NewHeadlessRule(s.ctx, ruleOptions) if err != nil { return E.Cause(err, "parse rule_set.rules.[", i, "]") } + rules[i] = rule + ruleCount += rule.RuleCount() } var metadata adapter.RuleSetMetadata metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) @@ -125,6 +145,7 @@ func (s *abstractRuleSet) reloadRules(headlessRules []option.HeadlessRule, ruleS metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) s.access.Lock() s.rules = rules + s.ruleCount = ruleCount s.metadata = metadata callbacks := s.callbacks.Array() s.access.Unlock() diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index e9bab2bbfc..d8bb0d0b30 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -31,6 +31,8 @@ func NewLocalRuleSet(ctx context.Context, logger logger.ContextLogger, options o ctx: ctx, logger: logger, tag: options.Tag, + sType: options.Type, + format: options.Format, }, } if options.Type == C.RuleSetTypeInline { @@ -42,7 +44,6 @@ func NewLocalRuleSet(ctx context.Context, logger logger.ContextLogger, options o return nil, err } } else { - ruleSet.format = options.Format path, err := ruleSet.getPath(ctx, options.Path) if err != nil { return nil, err @@ -119,6 +120,10 @@ func (s *LocalRuleSet) PostStart() error { return nil } +func (s *LocalRuleSet) Update(ctx context.Context) error { + return nil +} + func (s *LocalRuleSet) Close() error { s.rules = nil return common.Close(common.PtrOrNil(s.watcher)) diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index dd25255690..d4306a360a 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -135,6 +135,16 @@ func (s *RemoteRuleSet) update() { } } +func (s *RemoteRuleSet) Update(ctx context.Context) error { + err := s.fetch(log.ContextWithNewID(ctx), nil) + if err != nil { + return err + } else if s.refs.Load() == 0 { + s.rules = nil + } + return nil +} + func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPStartContext) error { s.logger.DebugContext(ctx, "updating rule-set ", s.tag, " from URL: ", s.options.URL) var httpClient *http.Client From 405551ae3a1e7aefb73304b14b393168351efb6d Mon Sep 17 00:00:00 2001 From: PuerNya Date: Fri, 5 Jul 2024 04:01:44 +0800 Subject: [PATCH 39/57] support multi `clash_mode` item in rule item --- docs/configuration/dns/rule.md | 4 +++- docs/configuration/dns/rule.zh.md | 4 +++- docs/configuration/route/rule.md | 4 +++- docs/configuration/route/rule.zh.md | 4 +++- experimental/clashapi.go | 8 ++++---- option/rule.go | 2 +- option/rule_dns.go | 2 +- route/rule/rule_default.go | 2 +- route/rule/rule_dns.go | 2 +- route/rule/rule_item_clash_mode.go | 19 +++++++++++++------ 10 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 97a4a7b3d5..cfd9c6ecae 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -135,7 +135,9 @@ icon: material/alert-decagram "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index e1288bb69e..36bad88a90 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -135,7 +135,9 @@ icon: material/alert-decagram "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 767e9ef756..94e66d72e9 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -135,7 +135,9 @@ icon: material/new-box "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index e581ae995d..674f4347f9 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -132,7 +132,9 @@ icon: material/new-box "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], diff --git a/experimental/clashapi.go b/experimental/clashapi.go index 4ad07c8b88..0e66daf5f1 100644 --- a/experimental/clashapi.go +++ b/experimental/clashapi.go @@ -55,8 +55,8 @@ func extraClashModeFromRule(rules []option.Rule) []string { for _, rule := range rules { switch rule.Type { case C.RuleTypeDefault: - if rule.DefaultOptions.ClashMode != "" { - clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + if len(rule.DefaultOptions.ClashMode) > 0 { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode...) } case C.RuleTypeLogical: clashMode = append(clashMode, extraClashModeFromRule(rule.LogicalOptions.Rules)...) @@ -70,8 +70,8 @@ func extraClashModeFromDNSRule(rules []option.DNSRule) []string { for _, rule := range rules { switch rule.Type { case C.RuleTypeDefault: - if rule.DefaultOptions.ClashMode != "" { - clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + if len(rule.DefaultOptions.ClashMode) > 0 { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode...) } case C.RuleTypeLogical: clashMode = append(clashMode, extraClashModeFromDNSRule(rule.LogicalOptions.Rules)...) diff --git a/option/rule.go b/option/rule.go index 6b24131ab3..cc65d1eddb 100644 --- a/option/rule.go +++ b/option/rule.go @@ -94,7 +94,7 @@ type RawDefaultRule struct { PackageName badoption.Listable[string] `json:"package_name,omitempty"` User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` - ClashMode string `json:"clash_mode,omitempty"` + ClashMode badoption.Listable[string] `json:"clash_mode,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` diff --git a/option/rule_dns.go b/option/rule_dns.go index 880b96ac54..31eb8e72f1 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -97,7 +97,7 @@ type RawDefaultDNSRule struct { User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` Outbound badoption.Listable[string] `json:"outbound,omitempty"` - ClashMode string `json:"clash_mode,omitempty"` + ClashMode badoption.Listable[string] `json:"clash_mode,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 28d3a156b7..81e3b3e1eb 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -219,7 +219,7 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } - if options.ClashMode != "" { + if len(options.ClashMode) > 0 { item := NewClashModeItem(ctx, options.ClashMode) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 9c93a6651f..dd45d98c6c 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -216,7 +216,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } - if options.ClashMode != "" { + if len(options.ClashMode) > 0 { item := NewClashModeItem(ctx, options.ClashMode) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) diff --git a/route/rule/rule_item_clash_mode.go b/route/rule/rule_item_clash_mode.go index fe2347a06f..98eed6b5c6 100644 --- a/route/rule/rule_item_clash_mode.go +++ b/route/rule/rule_item_clash_mode.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/service" ) @@ -13,13 +14,13 @@ var _ RuleItem = (*ClashModeItem)(nil) type ClashModeItem struct { ctx context.Context clashServer adapter.ClashServer - mode string + modes []string } -func NewClashModeItem(ctx context.Context, mode string) *ClashModeItem { +func NewClashModeItem(ctx context.Context, modes []string) *ClashModeItem { return &ClashModeItem{ - ctx: ctx, - mode: mode, + ctx: ctx, + modes: modes, } } @@ -32,9 +33,15 @@ func (r *ClashModeItem) Match(metadata *adapter.InboundContext) bool { if r.clashServer == nil { return false } - return strings.EqualFold(r.clashServer.Mode(), r.mode) + return common.Any(r.modes, func(mode string) bool { + return strings.EqualFold(r.clashServer.Mode(), mode) + }) } func (r *ClashModeItem) String() string { - return "clash_mode=" + r.mode + modeStr := r.modes[0] + if len(r.modes) > 1 { + modeStr = "[" + strings.Join(r.modes, ", ") + "]" + } + return "clash_mode=" + modeStr } From 33cfd485d3eb9212aeddc1c7878c3a7d1375ac9e Mon Sep 17 00:00:00 2001 From: PuerNya Date: Wed, 14 Aug 2024 21:45:27 +0800 Subject: [PATCH 40/57] set correct inbound type when using `auto_redirect` --- protocol/tun/inbound.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index b3be5889a3..70445be2f7 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -544,7 +544,7 @@ func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn ctx = log.ContextWithNewID(ctx) var metadata adapter.InboundContext metadata.Inbound = t.tag - metadata.InboundType = C.TypeTun + metadata.InboundType = C.TypeRedirect metadata.Source = source metadata.Destination = destination From 111f3cffe394789ddad8566fbd04cbd59e6c9bbc Mon Sep 17 00:00:00 2001 From: PuerNya Date: Tue, 6 May 2025 03:53:32 +0800 Subject: [PATCH 41/57] Support `GET` method for doh --- dns/transport/https.go | 22 +++++++++++++++++-- dns/transport/quic/http3.go | 20 ++++++++++++++++-- docs/configuration/dns/server/http3.md | 9 ++++++++ docs/configuration/dns/server/https.md | 9 ++++++++ option/dns.go | 29 +++++++++++++++++++++++++- protocol/tailscale/dns_transport.go | 4 ++-- 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/dns/transport/https.go b/dns/transport/https.go index b508e6eae5..b44c4b3ade 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -3,6 +3,7 @@ package transport import ( "bytes" "context" + "encoding/base64" "errors" "io" "net" @@ -44,6 +45,7 @@ type HTTPSTransport struct { logger logger.ContextLogger dialer N.Dialer destination *url.URL + method string headers http.Header transportAccess sync.Mutex transport *HTTPSTransportWrapper @@ -105,6 +107,7 @@ func NewHTTPS(ctx context.Context, logger log.ContextLogger, tag string, options logger, transportDialer, &destinationURL, + options.Method, headers, serverAddr, tlsConfig, @@ -116,6 +119,7 @@ func NewHTTPSRaw( logger log.ContextLogger, dialer N.Dialer, destination *url.URL, + method string, headers http.Header, serverAddr M.Socksaddr, tlsConfig tls.Config, @@ -124,6 +128,7 @@ func NewHTTPSRaw( TransportAdapter: adapter, logger: logger, dialer: dialer, + method: method, destination: destination, headers: headers, transport: NewHTTPSTransportWrapper(tls.NewDialer(dialer, tlsConfig), serverAddr), @@ -181,13 +186,26 @@ func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS requestBuffer.Release() return nil, err } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage)) + destination := *t.destination + var request *http.Request + var body io.Reader + switch t.method { + case http.MethodGet: + query := url.Values{} + query.Set("dns", base64.RawURLEncoding.EncodeToString(rawMessage)) + destination.RawQuery = query.Encode() + case http.MethodPost: + body = bytes.NewReader(rawMessage) + } + request, err = http.NewRequestWithContext(ctx, t.method, destination.String(), body) if err != nil { requestBuffer.Release() return nil, err } request.Header = t.headers.Clone() - request.Header.Set("Content-Type", MimeType) + if t.method == http.MethodPost { + request.Header.Set("Content-Type", MimeType) + } request.Header.Set("Accept", MimeType) t.transportAccess.Lock() currentTransport := t.transport diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go index c3a5ca81cb..7e5b7da922 100644 --- a/dns/transport/quic/http3.go +++ b/dns/transport/quic/http3.go @@ -3,6 +3,7 @@ package quic import ( "bytes" "context" + "encoding/base64" "io" "net" "net/http" @@ -43,6 +44,7 @@ type HTTP3Transport struct { logger logger.ContextLogger dialer N.Dialer destination *url.URL + method string headers http.Header serverAddr M.Socksaddr tlsConfig *tls.STDConfig @@ -106,6 +108,7 @@ func NewHTTP3(ctx context.Context, logger log.ContextLogger, tag string, options logger: logger, dialer: transportDialer, destination: &destinationURL, + method: options.Method, headers: headers, serverAddr: serverAddr, tlsConfig: stdConfig, @@ -162,13 +165,26 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS requestBuffer.Release() return nil, err } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage)) + destination := *t.destination + var request *http.Request + var body io.Reader + switch t.method { + case http.MethodGet: + query := url.Values{} + query.Set("dns", base64.RawURLEncoding.EncodeToString(rawMessage)) + destination.RawQuery = query.Encode() + case http.MethodPost: + body = bytes.NewReader(rawMessage) + } + request, err = http.NewRequestWithContext(ctx, t.method, destination.String(), body) if err != nil { requestBuffer.Release() return nil, err } request.Header = t.headers.Clone() - request.Header.Set("Content-Type", transport.MimeType) + if t.method == http.MethodPost { + request.Header.Set("Content-Type", transport.MimeType) + } request.Header.Set("Accept", transport.MimeType) t.transportAccess.Lock() currentTransport := t.transport diff --git a/docs/configuration/dns/server/http3.md b/docs/configuration/dns/server/http3.md index dd81ba2dae..5d6589f610 100644 --- a/docs/configuration/dns/server/http3.md +++ b/docs/configuration/dns/server/http3.md @@ -20,6 +20,7 @@ icon: material/new-box "server_port": 443, "path": "", + "method": "", "headers": {}, "tls": {}, @@ -58,6 +59,14 @@ The path of the DNS server. `/dns-query` will be used by default. +#### method + +The method of the DNS server. + +Only `GET` and `POST` are supported. + +`POST` will be used by default. + #### headers Additional headers to be sent to the DNS server. diff --git a/docs/configuration/dns/server/https.md b/docs/configuration/dns/server/https.md index 46e69a558e..10f2041ef1 100644 --- a/docs/configuration/dns/server/https.md +++ b/docs/configuration/dns/server/https.md @@ -20,6 +20,7 @@ icon: material/new-box "server_port": 443, "path": "", + "method": "", "headers": {}, "tls": {}, @@ -58,6 +59,14 @@ The path of the DNS server. `/dns-query` will be used by default. +#### method + +The method of the DNS server. + +Only `GET` and `POST` are supported. + +`POST` will be used by default. + #### headers Additional headers to be sent to the DNS server. diff --git a/option/dns.go b/option/dns.go index df8edfa630..11c10ad528 100644 --- a/option/dns.go +++ b/option/dns.go @@ -2,6 +2,7 @@ package option import ( "context" + "net/http" "net/netip" "net/url" @@ -392,7 +393,7 @@ type RemoteTLSDNSServerOptions struct { OutboundTLSOptionsContainer } -type RemoteHTTPSDNSServerOptions struct { +type _RemoteHTTPSDNSServerOptions struct { RemoteTLSDNSServerOptions Path string `json:"path,omitempty"` Method string `json:"method,omitempty"` @@ -403,6 +404,32 @@ type GroupDNSServerOptions struct { Servers []string `json:"servers"` } +type RemoteHTTPSDNSServerOptions _RemoteHTTPSDNSServerOptions + +func (o *RemoteHTTPSDNSServerOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { + switch o.Method { + case http.MethodPost: + o.Method = "" + } + return badjson.MarshallObjectsContext(ctx, (*_RemoteHTTPSDNSServerOptions)(o)) +} + +func (o *RemoteHTTPSDNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_RemoteHTTPSDNSServerOptions)(o)) + if err != nil { + return err + } + switch o.Method { + case "", http.MethodPost: + o.Method = http.MethodPost + case http.MethodGet: + o.Method = http.MethodGet + default: + return E.New("unsupported method") + } + return nil +} + type FakeIPDNSServerOptions struct { Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 521bb55146..599434f4a9 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -169,13 +169,13 @@ func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dn tlsConfig := common.Must1(tls.NewClient(t.ctx, t.logger, serverAddr.AddrString(), option.OutboundTLSOptions{ ALPN: []string{http2.NextProtoTLS, "http/1.1"}, })) - return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, tlsConfig), nil + return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.MethodPost, http.Header{}, serverAddr, tlsConfig), nil case "http": serverAddr = M.ParseSocksaddrHostPortStr(serverURL.Hostname(), serverURL.Port()) if serverAddr.Port == 0 { serverAddr.Port = 80 } - return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, nil), nil + return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.MethodPost, http.Header{}, serverAddr, nil), nil // case "tls": default: return nil, E.New("unknown resolver scheme: ", serverURL.Scheme) From c7b38d999f15682d1f5a2e83a6651f1cc7ff5f8d Mon Sep 17 00:00:00 2001 From: HystericalDragon Date: Thu, 11 Jul 2024 14:03:03 +0800 Subject: [PATCH 42/57] tls: Reject unknown SNI Co-authored-by: arm64v8a <48624112+arm64v8a@users.noreply.github.com> --- README.md | 25 +++++++++++++++++++++++++ common/tls/reality_server.go | 13 +++++++++++-- option/tls.go | 2 ++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d9a8d7a3c9..c181644719 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,31 @@ The universal proxy platform. https://sing-box.sagernet.org +## Inbound TLS + +```json +{ + "inbounds": [ + { + "type": "trojan", + "tag": "trojan-in", + "tls": { + "enabled": true, + "server_name": "sekai.love", + "certificate_path": "cert.pem", + "key_path": "key.key", + "reject_unknown_sni": true + } + } + ] +} +``` + +Reject unknown sni: If the server name of connection is not equal to `server_name` and not be included in certificate, +it will be rejected. + +拒绝未知 SNI:如果连接的 server name 与 `server_name` 不符 且 证书中不包含它,则拒绝连接。 + ## URLTest Fallback 支持 按照**可用性**和**顺序**选择出站 diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go index 5fc684756b..703c015b26 100644 --- a/common/tls/reality_server.go +++ b/common/tls/reality_server.go @@ -26,7 +26,8 @@ import ( var _ ServerConfigCompat = (*RealityServerConfig)(nil) type RealityServerConfig struct { - config *utls.RealityConfig + config *utls.RealityConfig + rejectUnknownSNI bool } func NewRealityServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { @@ -126,7 +127,7 @@ func NewRealityServer(ctx context.Context, logger log.ContextLogger, options opt if options.ECH != nil && options.ECH.Enabled { return nil, E.New("Reality is conflict with ECH") } - var config ServerConfig = &RealityServerConfig{&tlsConfig} + var config ServerConfig = &RealityServerConfig{&tlsConfig, options.RejectUnknownSNI} if options.KernelTx || options.KernelRx { if !C.IsLinux { return nil, E.New("kTLS is only supported on Linux") @@ -182,6 +183,14 @@ func (c *RealityServerConfig) ServerHandshake(ctx context.Context, conn net.Conn if err != nil { return nil, err } + if c.rejectUnknownSNI { + sni := tlsConn.ConnectionState().ServerName + loadedSNI := c.config.ServerNames[sni] + if !loadedSNI && sni != c.config.ServerName { + _ = tlsConn.Close() + return nil, E.Cause(err, "unknown server name") + } + } return &realityConnWrapper{Conn: tlsConn}, nil } diff --git a/option/tls.go b/option/tls.go index e1267bb52e..8be3f3a826 100644 --- a/option/tls.go +++ b/option/tls.go @@ -31,6 +31,8 @@ type InboundTLSOptions struct { ACME *InboundACMEOptions `json:"acme,omitempty"` ECH *InboundECHOptions `json:"ech,omitempty"` Reality *InboundRealityOptions `json:"reality,omitempty"` + + RejectUnknownSNI bool `json:"reject_unknown_sni,omitempty"` } type ClientAuthType tls.ClientAuthType From b7eb8f01b2109b765d77e07140e063a8bcce30ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E5=AE=B9?= Date: Sun, 6 Jul 2025 15:41:13 +0800 Subject: [PATCH 43/57] DNS: Add `reuse` option for TCP --- README.md | 22 ++++++ common/expiringpool/pool.go | 149 ++++++++++++++++++++++++++++++++++++ dns/transport/tcp.go | 50 ++++++++++-- dns/transport/tls.go | 11 +-- option/dns.go | 5 ++ 5 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 common/expiringpool/pool.go diff --git a/README.md b/README.md index c181644719..0accc386a5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,28 @@ it will be rejected. 拒绝未知 SNI:如果连接的 server name 与 `server_name` 不符 且 证书中不包含它,则拒绝连接。 +## DNS + +### TCP + +```json +{ + "dns": { + "servers": [ + { + "type": "tcp", + "tag": "cloudlfare-tcp", + "server": "1.1.1.1", + "server_port": 53, + "reuse": true + } + ] + } +} +``` + +- `reuse`: Reuse TCP connection. + ## URLTest Fallback 支持 按照**可用性**和**顺序**选择出站 diff --git a/common/expiringpool/pool.go b/common/expiringpool/pool.go new file mode 100644 index 0000000000..5598ed14e6 --- /dev/null +++ b/common/expiringpool/pool.go @@ -0,0 +1,149 @@ +package expiringpool + +import ( + "container/heap" + "context" + "sync" + "time" + + "github.com/sagernet/sing/common" +) + +type item[T any] struct { + value T + expiresAt time.Time + index int // heap index +} + +type minHeap[T any] []*item[T] + +func (h minHeap[T]) Len() int { + return len(h) +} + +func (h minHeap[T]) Less(i, j int) bool { + return h[i].expiresAt.Before(h[j].expiresAt) +} + +func (h minHeap[T]) Swap(i, j int) { + h[i], h[j] = h[j], h[i] + h[i].index, h[j].index = i, j +} + +func (h *minHeap[T]) Push(x any) { + it := x.(*item[T]) + it.index = len(*h) + *h = append(*h, it) +} + +func (h *minHeap[T]) Pop() any { + old := *h + n := len(old) + it := old[n-1] + it.index = -1 + *h = old[0 : n-1] + return it +} + +type ExpiringPool[T comparable] struct { + ctx context.Context + onClean func(T) + expire time.Duration + + access sync.Mutex + heap minHeap[T] + items map[T]*item[T] + + cancel context.CancelFunc +} + +func New[T comparable](ctx context.Context, expire time.Duration, onClean func(T)) *ExpiringPool[T] { + ctx, cancel := context.WithCancel(ctx) + return &ExpiringPool[T]{ + ctx: ctx, + onClean: onClean, + expire: expire, + items: make(map[T]*item[T]), + cancel: cancel, + } +} + +func (e *ExpiringPool[T]) Start() { + go e.cleanLoop() +} + +func (e *ExpiringPool[T]) cleanLoop() { + for { + e.access.Lock() + if len(e.heap) == 0 { + e.access.Unlock() + select { + case <-e.ctx.Done(): + return + case <-time.After(e.expire): + continue + } + } + + next := e.heap[0] + now := time.Now() + wait := next.expiresAt.Sub(now) + e.access.Unlock() + + if wait > 0 { + select { + case <-e.ctx.Done(): + return + case <-time.After(wait): + continue + } + } + + e.access.Lock() + // re-check + if len(e.heap) == 0 || !e.heap[0].expiresAt.Before(time.Now()) { + e.access.Unlock() + continue + } + it := heap.Pop(&e.heap).(*item[T]) + delete(e.items, it.value) + e.access.Unlock() + + e.onClean(it.value) + } +} + +func (e *ExpiringPool[T]) Get() T { + e.access.Lock() + defer e.access.Unlock() + if len(e.heap) <= 0 { + return common.DefaultValue[T]() + } + // take oldest + it := heap.Pop(&e.heap).(*item[T]) + delete(e.items, it.value) + return it.value +} + +func (e *ExpiringPool[T]) Put(value T) { + e.access.Lock() + defer e.access.Unlock() + expiresAt := time.Now().Add(e.expire) + it := &item[T]{value: value, expiresAt: expiresAt} + heap.Push(&e.heap, it) + e.items[value] = it +} + +func (e *ExpiringPool[T]) Close() { + e.access.Lock() + defer e.access.Unlock() + if e.cancel != nil { + e.cancel() + e.cancel = nil + } + // clean remaining + for len(e.heap) > 0 { + it := heap.Pop(&e.heap).(*item[T]) + e.onClean(it.value) + } +} diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index 59333de8df..5a15c4ab8a 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -7,6 +7,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/expiringpool" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/log" @@ -23,17 +24,19 @@ import ( var _ adapter.DNSTransport = (*TCPTransport)(nil) func RegisterTCP(registry *dns.TransportRegistry) { - dns.RegisterTransport[option.RemoteDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) + dns.RegisterTransport[option.RemoteTCPDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) } type TCPTransport struct { dns.TransportAdapter dialer N.Dialer serverAddr M.Socksaddr + + connections *expiringpool.ExpiringPool[*reusableDNSConn] } -func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { - transportDialer, err := dns.NewRemoteDialer(ctx, options) +func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTCPDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions) if err != nil { return nil, err } @@ -44,21 +47,34 @@ func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } - return &TCPTransport{ - TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), + transport := &TCPTransport{ + TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options.RemoteDNSServerOptions), dialer: transportDialer, serverAddr: serverAddr, - }, nil + } + if options.Reuse { + transport.connections = expiringpool.New(ctx, C.TCPKeepAliveInterval, func(t *reusableDNSConn) { + _ = t.Close() + }) + } + return transport, nil } func (t *TCPTransport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } + if t.connections != nil { + t.connections.Start() + } return dialer.InitializeDetour(t.dialer) } func (t *TCPTransport) Close() error { + if t.connections == nil { + return nil + } + t.connections.Close() return nil } @@ -66,19 +82,37 @@ func (t *TCPTransport) Reset() { } func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + if t.connections != nil { + conn := t.connections.Get() + if conn != nil { + response, err := t.exchange(message, conn) + if err == nil { + return response, nil + } + } + } conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) if err != nil { return nil, E.Cause(err, "dial TCP connection") } - defer conn.Close() - err = WriteMessage(conn, 0, message) + return t.exchange(message, &reusableDNSConn{Conn: conn}) +} + +func (t *TCPTransport) exchange(message *mDNS.Msg, conn *reusableDNSConn) (*mDNS.Msg, error) { + conn.queryId++ + err := WriteMessage(conn, conn.queryId, message) if err != nil { + _ = conn.Close() return nil, E.Cause(err, "write request") } response, err := ReadMessage(conn) if err != nil { + _ = conn.Close() return nil, E.Cause(err, "read response") } + if t.connections != nil { + t.connections.Put(conn) + } return response, nil } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 4d463296b1..87bcd7468b 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -2,6 +2,7 @@ package transport import ( "context" + "net" "sync" "time" @@ -35,11 +36,11 @@ type TLSTransport struct { serverAddr M.Socksaddr tlsConfig tls.Config access sync.Mutex - connections list.List[*tlsDNSConn] + connections list.List[*reusableDNSConn] } -type tlsDNSConn struct { - tls.Conn +type reusableDNSConn struct { + net.Conn queryId uint16 } @@ -123,10 +124,10 @@ func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M if err != nil { return nil, E.Cause(err, "dial TLS connection") } - return t.exchange(ctx, message, &tlsDNSConn{Conn: tlsConn}) + return t.exchange(ctx, message, &reusableDNSConn{Conn: tlsConn}) } -func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { +func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *reusableDNSConn) (*mDNS.Msg, error) { if deadline, ok := ctx.Deadline(); ok { conn.SetDeadline(deadline) } diff --git a/option/dns.go b/option/dns.go index 11c10ad528..6d825df4fa 100644 --- a/option/dns.go +++ b/option/dns.go @@ -388,6 +388,11 @@ type RemoteDNSServerOptions struct { LegacyAddressFallbackDelay badoption.Duration `json:"-"` } +type RemoteTCPDNSServerOptions struct { + RemoteDNSServerOptions + Reuse bool `json:"reuse,omitempty"` +} + type RemoteTLSDNSServerOptions struct { RemoteDNSServerOptions OutboundTLSOptionsContainer From 8882d8e2849e816351edd8d0c74fd0050de8a1ef Mon Sep 17 00:00:00 2001 From: lurixo Date: Sat, 6 Dec 2025 17:39:52 +0800 Subject: [PATCH 44/57] feat: add `tcp_keep_alive_count` option for listen fields and dial fields Add TCPKeepAliveCount to ListenOptions (inbound) and apply it in listener_tcp.go, complementing the existing outbound-only support. Also move TCPKeepAliveCount in DialerOptions to sit next to other TCP keepalive fields for consistency. --- README.md | 19 +++++++++++++++++++ common/dialer/default.go | 5 +++++ common/listener/listener_tcp.go | 5 +++++ docs/configuration/shared/dial.md | 8 ++++++++ docs/configuration/shared/dial.zh.md | 8 ++++++++ docs/configuration/shared/listen.md | 12 +++++++++++- docs/configuration/shared/listen.zh.md | 12 +++++++++++- option/inbound.go | 1 + option/outbound.go | 1 + 9 files changed, 69 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0accc386a5..62f1663faf 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,25 @@ it will be rejected. 拒绝未知 SNI:如果连接的 server name 与 `server_name` 不符 且 证书中不包含它,则拒绝连接。 +## Dialer + +```json +{ + "outbounds": [ + { + "type": "direct", + "tag": "direct", + "tcp_keep_alive": "5m", + "tcp_keep_alive_interval": "75s", + "tcp_keep_alive_count": 0, + "disable_tcp_keep_alive": false + } + ] +} +``` + +TCP Keep alive options. + ## DNS ### TCP diff --git a/common/dialer/default.go b/common/dialer/default.go index 6b2379f4d4..6c6ead3544 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -158,10 +158,15 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if keepInterval == 0 { keepInterval = C.TCPKeepAliveInterval } + keepCount := options.TCPKeepAliveCount + if keepCount < 0 { + keepCount = 0 + } dialer.KeepAliveConfig = net.KeepAliveConfig{ Enable: true, Idle: keepIdle, Interval: keepInterval, + Count: keepCount, } } var udpFragment bool diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index d2653af2f9..b245438543 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -43,10 +43,15 @@ func (l *Listener) ListenTCP() (net.Listener, error) { if keepInterval == 0 { keepInterval = C.TCPKeepAliveInterval } + keepCount := l.listenOptions.TCPKeepAliveCount + if keepCount < 0 { + keepCount = 0 + } listenConfig.KeepAliveConfig = net.KeepAliveConfig{ Enable: true, Idle: keepIdle, Interval: keepInterval, + Count: keepCount, } } if l.listenOptions.TCPMultiPath { diff --git a/docs/configuration/shared/dial.md b/docs/configuration/shared/dial.md index 306952fc4a..3493268f15 100644 --- a/docs/configuration/shared/dial.md +++ b/docs/configuration/shared/dial.md @@ -7,6 +7,7 @@ icon: material/new-box :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) :material-plus: [tcp_keep_alive](#tcp_keep_alive) :material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) :material-plus: [bind_address_no_port](#bind_address_no_port) !!! quote "Changes in sing-box 1.12.0" @@ -40,6 +41,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "domain_resolver": "", // or {} @@ -159,6 +161,12 @@ TCP keep alive interval. `75s` will be used by default. +#### tcp_keep_alive_count + +TCP keep-alive probe count. + +Uses system default if not set or set to `0`. + #### udp_fragment Enable UDP fragmentation. diff --git a/docs/configuration/shared/dial.zh.md b/docs/configuration/shared/dial.zh.md index 4930935178..6b82deb4f6 100644 --- a/docs/configuration/shared/dial.zh.md +++ b/docs/configuration/shared/dial.zh.md @@ -7,6 +7,7 @@ icon: material/new-box :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) :material-plus: [tcp_keep_alive](#tcp_keep_alive) :material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) :material-plus: [bind_address_no_port](#bind_address_no_port) !!! quote "sing-box 1.12.0 中的更改" @@ -40,6 +41,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "domain_resolver": "", // 或 {} @@ -157,6 +159,12 @@ TCP keep alive 间隔。 默认使用 `75s`。 +#### tcp_keep_alive_count + +TCP keep-alive 探测次数。 + +未设置或设置为 `0` 时使用系统默认值。 + #### udp_fragment 启用 UDP 分段。 diff --git a/docs/configuration/shared/listen.md b/docs/configuration/shared/listen.md index fc64237142..87be405405 100644 --- a/docs/configuration/shared/listen.md +++ b/docs/configuration/shared/listen.md @@ -5,7 +5,8 @@ icon: material/new-box !!! quote "Changes in sing-box 1.13.0" :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) - :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) !!! quote "Changes in sing-box 1.12.0" @@ -37,6 +38,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "udp_timeout": "", "detour": "", @@ -131,6 +133,14 @@ TCP keep alive interval. `75s` will be used by default. +#### tcp_keep_alive_count + +!!! question "Since sing-box 1.13.0" + +TCP keep-alive probe count. + +Uses system default if not set or set to `0`. + #### udp_fragment Enable UDP fragmentation. diff --git a/docs/configuration/shared/listen.zh.md b/docs/configuration/shared/listen.zh.md index e4189f4e98..c49a558ba4 100644 --- a/docs/configuration/shared/listen.zh.md +++ b/docs/configuration/shared/listen.zh.md @@ -5,7 +5,8 @@ icon: material/new-box !!! quote "sing-box 1.13.0 中的更改" :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) - :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) !!! quote "sing-box 1.12.0 中的更改" @@ -37,6 +38,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "udp_timeout": "", "detour": "", @@ -131,6 +133,14 @@ TCP keep alive 间隔。 默认使用 `75s`。 +#### tcp_keep_alive_count + +!!! question "自 sing-box 1.13.0 起" + +TCP keep-alive 探测次数。 + +未设置或设置为 `0` 时使用系统默认值。 + #### udp_fragment 启用 UDP 分段。 diff --git a/option/inbound.go b/option/inbound.go index 548d486d74..151b567e37 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -67,6 +67,7 @@ type ListenOptions struct { DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPKeepAliveCount int `json:"tcp_keep_alive_count,omitempty"` TCPFastOpen bool `json:"tcp_fast_open,omitempty"` TCPMultiPath bool `json:"tcp_multi_path,omitempty"` UDPFragment *bool `json:"udp_fragment,omitempty"` diff --git a/option/outbound.go b/option/outbound.go index cb388c4439..9f9045cc96 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -80,6 +80,7 @@ type DialerOptions struct { DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPKeepAliveCount int `json:"tcp_keep_alive_count,omitempty"` UDPFragment *bool `json:"udp_fragment,omitempty"` UDPFragmentDefault bool `json:"-"` DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` From 4824d2cfab3cfd84b4e584fec708e6a74608199a Mon Sep 17 00:00:00 2001 From: reF1nd Date: Fri, 23 May 2025 17:52:04 +0800 Subject: [PATCH 45/57] Add TCP keep alive options to outbound provider's `override_dialer` --- .../configuration/provider/override_dialer.md | 6 ++- .../provider/override_dialer.zh.md | 6 ++- option/provider.go | 37 +++++++++++-------- provider/parser/parser.go | 12 ++++++ 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/configuration/provider/override_dialer.md b/docs/configuration/provider/override_dialer.md index 8aa26c519c..446a808050 100644 --- a/docs/configuration/provider/override_dialer.md +++ b/docs/configuration/provider/override_dialer.md @@ -17,6 +17,10 @@ "network_type": [], "fallback_network_type": [], "fallback_delay": "300ms", + "tcp_keep_alive": "5m", + "tcp_keep_alive_interval": "75s", + "tcp_keep_alive_count": 0, + "disable_tcp_keep_alive": false, // Deprecated @@ -26,4 +30,4 @@ ### Fields -`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `domain_strategy` see [Dial Fields](/configuration/shared/dial). +`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `tcp_keep_alive` `tcp_keep_alive_interval` `tcp_keep_alive_count` `disable_tcp_keep_alive` `domain_strategy` see [Dial Fields](/configuration/shared/dial). diff --git a/docs/configuration/provider/override_dialer.zh.md b/docs/configuration/provider/override_dialer.zh.md index 3bc40e0c61..e0c697a14f 100644 --- a/docs/configuration/provider/override_dialer.zh.md +++ b/docs/configuration/provider/override_dialer.zh.md @@ -17,6 +17,10 @@ "network_type": [], "fallback_network_type": [], "fallback_delay": "300ms", + "tcp_keep_alive": "5m", + "tcp_keep_alive_interval": "75s", + "tcp_keep_alive_count": 0, + "disable_tcp_keep_alive": false, // 废弃的 @@ -26,4 +30,4 @@ ### 字段 -`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `domain_strategy` 详情参阅 [拨号字段](/zh/configuration/shared/dial)。 +`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `tcp_keep_alive` `tcp_keep_alive_interval` `tcp_keep_alive_count` `disable_tcp_keep_alive` `domain_strategy` 详情参阅 [拨号字段](/zh/configuration/shared/dial)。 diff --git a/option/provider.go b/option/provider.go index 6006157a88..e77a7847a0 100644 --- a/option/provider.go +++ b/option/provider.go @@ -80,22 +80,27 @@ type ProviderHealthCheckOptions struct { } type OverrideDialerOptions struct { - Detour *string `json:"detour,omitempty"` - BindInterface *string `json:"bind_interface,omitempty"` - Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` - Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` - ProtectPath *string `json:"protect_path,omitempty"` - RoutingMark *FwMark `json:"routing_mark,omitempty"` - ReuseAddr *bool `json:"reuse_addr,omitempty"` - ConnectTimeout *badoption.Duration `json:"connect_timeout,omitempty"` - TCPFastOpen *bool `json:"tcp_fast_open,omitempty"` - TCPMultiPath *bool `json:"tcp_multi_path,omitempty"` - UDPFragment *bool `json:"udp_fragment,omitempty"` - DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` - NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` - NetworkType *badoption.Listable[InterfaceType] `json:"network_type,omitempty"` - FallbackNetworkType *badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` - FallbackDelay *badoption.Duration `json:"fallback_delay,omitempty"` + Detour *string `json:"detour,omitempty"` + BindInterface *string `json:"bind_interface,omitempty"` + Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` + ProtectPath *string `json:"protect_path,omitempty"` + RoutingMark *FwMark `json:"routing_mark,omitempty"` + ReuseAddr *bool `json:"reuse_addr,omitempty"` + ConnectTimeout *badoption.Duration `json:"connect_timeout,omitempty"` + TCPFastOpen *bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath *bool `json:"tcp_multi_path,omitempty"` + TCPKeepAlive *badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval *badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + NetworkType *badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + FallbackNetworkType *badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` + FallbackDelay *badoption.Duration `json:"fallback_delay,omitempty"` + + TCPKeepAliveCount *int `json:"tcp_keep_alive_count,omitempty"` + DisableTCPKeepAlive *bool `json:"disable_tcp_keep_alive,omitempty"` // Deprecated: migrated to domain resolver DomainStrategy *DomainStrategy `json:"domain_strategy,omitempty"` diff --git a/provider/parser/parser.go b/provider/parser/parser.go index e2242bbd6f..a89940e96b 100644 --- a/provider/parser/parser.go +++ b/provider/parser/parser.go @@ -133,6 +133,12 @@ func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *o if overrideDialerOptions.TCPMultiPath != nil { options.TCPMultiPath = *overrideDialerOptions.TCPMultiPath } + if overrideDialerOptions.TCPKeepAlive != nil { + options.TCPKeepAlive = *overrideDialerOptions.TCPKeepAlive + } + if overrideDialerOptions.TCPKeepAliveInterval != nil { + options.TCPKeepAliveInterval = *overrideDialerOptions.TCPKeepAliveInterval + } if overrideDialerOptions.UDPFragment != nil { options.UDPFragment = overrideDialerOptions.UDPFragment } @@ -151,6 +157,12 @@ func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *o if overrideDialerOptions.FallbackDelay != nil { options.FallbackDelay = *overrideDialerOptions.FallbackDelay } + if overrideDialerOptions.TCPKeepAliveCount != nil { + options.TCPKeepAliveCount = *overrideDialerOptions.TCPKeepAliveCount + } + if overrideDialerOptions.DisableTCPKeepAlive != nil { + options.DisableTCPKeepAlive = *overrideDialerOptions.DisableTCPKeepAlive + } //nolint:staticcheck if overrideDialerOptions.DomainStrategy != nil { From 8a2eb75a0d7fccf6f4be1fecf84a8f4fea7da051 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Mon, 24 Nov 2025 16:29:15 +0800 Subject: [PATCH 46/57] tls: Add `server_names` and enhance `reject_unknown_sni` --- README.md | 20 +++++++++++++++++--- common/tls/reality_server.go | 23 +++++++++++++++++++---- common/tls/std_server.go | 26 ++++++++++++++++++++++++++ option/tls.go | 1 + 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 62f1663faf..d96e56d0f8 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,29 @@ https://sing-box.sagernet.org "key_path": "key.key", "reject_unknown_sni": true } + }, + { + "type": "anytls", + "tag": "anytls-in", + "tls": { + "enabled": true, + "server_names": [ + "sagernet.sekai.love", + "sekai.love" + ], + "certificate_path": "cert.pem", + "key_path": "key.key", + "reject_unknown_sni": true + } } ] } ``` -Reject unknown sni: If the server name of connection is not equal to `server_name` and not be included in certificate, -it will be rejected. +Reject unknown SNI: If the server name of connection does not match `server_name` or any domain in `server_names`, +and is not included in the certificate, it will be rejected. -拒绝未知 SNI:如果连接的 server name 与 `server_name` 不符 且 证书中不包含它,则拒绝连接。 +拒绝未知 SNI:如果连接的 server name 与 `server_name` 或者 `server_names` 中包含的域名 不符 且 证书中不包含它,则拒绝连接。 ## Dialer diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go index 703c015b26..95a3b497ef 100644 --- a/common/tls/reality_server.go +++ b/common/tls/reality_server.go @@ -33,6 +33,9 @@ type RealityServerConfig struct { func NewRealityServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { var tlsConfig utls.RealityConfig + if options.ServerName != "" && len(options.ServerNames) > 0 { + return nil, E.New("server_name and server_names cannot be configured at the same time") + } if options.ACME != nil && len(options.ACME.Domain) > 0 { return nil, E.New("acme is unavailable in reality") } @@ -88,7 +91,15 @@ func NewRealityServer(ctx context.Context, logger log.ContextLogger, options opt tlsConfig.Type = N.NetworkTCP tlsConfig.Dest = options.Reality.Handshake.ServerOptions.Build().String() - tlsConfig.ServerNames = map[string]bool{options.ServerName: true} + tlsConfig.ServerNames = make(map[string]bool) + if options.ServerName != "" { + tlsConfig.ServerNames[options.ServerName] = true + } + for _, name := range options.ServerNames { + if name != "" { + tlsConfig.ServerNames[name] = true + } + } privateKey, err := base64.RawURLEncoding.DecodeString(options.Reality.PrivateKey) if err != nil { return nil, E.Cause(err, "decode private key") @@ -185,10 +196,14 @@ func (c *RealityServerConfig) ServerHandshake(ctx context.Context, conn net.Conn } if c.rejectUnknownSNI { sni := tlsConn.ConnectionState().ServerName - loadedSNI := c.config.ServerNames[sni] - if !loadedSNI && sni != c.config.ServerName { + if len(c.config.ServerNames) > 0 { + if sni == "" || !c.config.ServerNames[sni] { + _ = tlsConn.Close() + return nil, E.New("unknown or missing server name") + } + } else if sni != "" { _ = tlsConn.Close() - return nil, E.Cause(err, "unknown server name") + return nil, E.New("unknown server name: no server names configured") } } return &realityConnWrapper{Conn: tlsConn}, nil diff --git a/common/tls/std_server.go b/common/tls/std_server.go index 760c4b3a7f..b509c72eac 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -34,6 +34,8 @@ type STDServerConfig struct { clientCertificatePath []string echKeyPath string watcher *fswatch.Watcher + rejectUnknownSNI bool + serverNames map[string]bool } func (c *STDServerConfig) ServerName() string { @@ -216,6 +218,9 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. if !options.Enabled { return nil, nil } + if options.ServerName != "" && len(options.ServerNames) > 0 { + return nil, E.New("server_name and server_names cannot be configured at the same time") + } var tlsConfig *tls.Config var acmeService adapter.SimpleLifecycle var err error @@ -357,6 +362,15 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. return nil, err } } + serverNames := make(map[string]bool) + if options.ServerName != "" { + serverNames[options.ServerName] = true + } + for _, name := range options.ServerNames { + if name != "" { + serverNames[name] = true + } + } serverConfig := &STDServerConfig{ config: tlsConfig, logger: logger, @@ -367,10 +381,22 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. clientCertificatePath: options.ClientCertificatePath, keyPath: options.KeyPath, echKeyPath: echKeyPath, + rejectUnknownSNI: options.RejectUnknownSNI, + serverNames: serverNames, } serverConfig.config.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) { serverConfig.access.Lock() defer serverConfig.access.Unlock() + if serverConfig.rejectUnknownSNI { + sni := info.ServerName + if len(serverConfig.serverNames) > 0 { + if sni == "" || !serverConfig.serverNames[sni] { + return nil, E.New("unknown or missing server name") + } + } else if sni != "" { + return nil, E.New("unknown server name: no server names configured") + } + } return serverConfig.config, nil } var config ServerConfig = serverConfig diff --git a/option/tls.go b/option/tls.go index 8be3f3a826..80be61d3ad 100644 --- a/option/tls.go +++ b/option/tls.go @@ -12,6 +12,7 @@ import ( type InboundTLSOptions struct { Enabled bool `json:"enabled,omitempty"` ServerName string `json:"server_name,omitempty"` + ServerNames badoption.Listable[string] `json:"server_names,omitempty"` Insecure bool `json:"insecure,omitempty"` ALPN badoption.Listable[string] `json:"alpn,omitempty"` MinVersion string `json:"min_version,omitempty"` From 0ef2b6d6290c8f7a5f2f99efa02123027d16ecff Mon Sep 17 00:00:00 2001 From: reF1nd Date: Thu, 27 Nov 2025 02:26:30 +0800 Subject: [PATCH 47/57] DNS: Add pipeline support for TCP and TLS --- README.md | 27 +- dns/transport/tcp.go | 455 ++++++++++++++++++++++++++++++++-- dns/transport/tls.go | 272 +++++++++++++++----- option/dns.go | 6 +- service/resolved/transport.go | 6 +- 5 files changed, 669 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index d96e56d0f8..ed1ad1e8bb 100644 --- a/README.md +++ b/README.md @@ -87,14 +87,37 @@ TCP Keep alive options. "tag": "cloudlfare-tcp", "server": "1.1.1.1", "server_port": 53, - "reuse": true + "reuse": true, + "pipeline": true } ] } } ``` -- `reuse`: Reuse TCP connection. +- `reuse`: Reuse TCP connection. Always enabled when `pipeline` is true. +- `pipeline`: Enable DNS pipelining (RFC 9210). Multiple queries can be sent without waiting for responses, improving performance. + +### DoT + +```json +{ + "dns": { + "servers": [ + { + "type": "tls", + "tag": "cloudflare-dot", + "server": "1.1.1.1", + "server_port": 853, + "pipeline": true + } + ] + } +} +``` + +- `pipeline`: Enable DNS pipelining (RFC 9210). Multiple queries can be sent over the same TLS connection without waiting for responses, +significantly improving performance in high-concurrency scenarios. ## URLTest Fallback 支持 diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index 5a15c4ab8a..6ec832ebec 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -4,6 +4,10 @@ import ( "context" "encoding/binary" "io" + "net" + "sync" + "sync/atomic" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" @@ -15,6 +19,7 @@ import ( "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -23,16 +28,34 @@ import ( var _ adapter.DNSTransport = (*TCPTransport)(nil) +type dnsTransportManager interface { + removeActiveConn(conn *reuseableDNSConn) + markPipelineDetected() bool + isPipelineDetected() bool + getDetectionCounters() (consecutiveOutOfOrder, outOfOrderCount, totalResponses *int32) +} + func RegisterTCP(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteTCPDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) } type TCPTransport struct { dns.TransportAdapter + logger logger.ContextLogger dialer N.Dialer serverAddr M.Socksaddr - connections *expiringpool.ExpiringPool[*reusableDNSConn] + connections *expiringpool.ExpiringPool[*reuseableDNSConn] + enablePipeline bool + idleTimeout time.Duration + disableKeepAlive bool + maxQueries int + activeConns []*reuseableDNSConn + activeAccess sync.Mutex + pipelineDetected int32 + consecutiveOutOfOrder int32 + outOfOrderCount int32 + totalResponses int32 } func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTCPDNSServerOptions) (adapter.DNSTransport, error) { @@ -47,14 +70,47 @@ func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } + enableConnReuse := options.Reuse + if options.Pipeline { + enableConnReuse = true + } + var poolIdleTimeout time.Duration + if options.DisableTCPKeepAlive { + poolIdleTimeout = 2 * time.Minute + } else { + var keepAliveIdle, keepAliveInterval time.Duration + if options.TCPKeepAlive != 0 { + keepAliveIdle = time.Duration(options.TCPKeepAlive) + } else { + keepAliveIdle = C.TCPKeepAliveInitial + } + if options.TCPKeepAliveInterval != 0 { + keepAliveInterval = time.Duration(options.TCPKeepAliveInterval) + } else { + keepAliveInterval = C.TCPKeepAliveInterval + } + poolIdleTimeout = keepAliveIdle + keepAliveInterval + } + maxQueries := options.MaxQueries + if maxQueries <= 0 { + maxQueries = 0 + } + if !options.Pipeline && maxQueries > 0 { + maxQueries = 0 + } transport := &TCPTransport{ TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options.RemoteDNSServerOptions), + logger: logger, dialer: transportDialer, serverAddr: serverAddr, + enablePipeline: options.Pipeline, + idleTimeout: poolIdleTimeout, + disableKeepAlive: options.DisableTCPKeepAlive, + maxQueries: maxQueries, } - if options.Reuse { - transport.connections = expiringpool.New(ctx, C.TCPKeepAliveInterval, func(t *reusableDNSConn) { - _ = t.Close() + if enableConnReuse { + transport.connections = expiringpool.New(ctx, poolIdleTimeout, func(conn *reuseableDNSConn) { + conn.Close() }) } return transport, nil @@ -71,10 +127,9 @@ func (t *TCPTransport) Start(stage adapter.StartStage) error { } func (t *TCPTransport) Close() error { - if t.connections == nil { - return nil + if t.connections != nil { + t.connections.Close() } - t.connections.Close() return nil } @@ -82,38 +137,161 @@ func (t *TCPTransport) Reset() { } func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if t.connections != nil { - conn := t.connections.Get() + if t.connections == nil { + return t.createNewConnection(ctx, message) + } + + if t.enablePipeline { + if t.maxQueries == 0 { + conn := t.getValidConnFromPool() + if conn != nil { + return conn.Exchange(ctx, message) + } + return t.createNewConnection(ctx, message) + } else { + conn := t.findAndReserveActiveConn() + if conn != nil { + return conn.exchangeWithoutIncrement(ctx, message) + } + + conn = t.getValidConnFromPool() + if conn != nil { + t.addActiveConn(conn) + return conn.Exchange(ctx, message) + } + + return t.createNewConnection(ctx, message) + } + } else { + conn := t.getValidConnFromPool() if conn != nil { - response, err := t.exchange(message, conn) + response, err := conn.Exchange(ctx, message) if err == nil { return response, nil } } + return t.createNewConnection(ctx, message) } - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TCP connection") +} + +func (t *TCPTransport) getValidConnFromPool() *reuseableDNSConn { + conn := t.connections.Get() + if conn == nil { + return nil + } + + select { + case <-conn.done: + return nil + default: + return conn } - return t.exchange(message, &reusableDNSConn{Conn: conn}) } -func (t *TCPTransport) exchange(message *mDNS.Msg, conn *reusableDNSConn) (*mDNS.Msg, error) { - conn.queryId++ - err := WriteMessage(conn, conn.queryId, message) - if err != nil { - _ = conn.Close() - return nil, E.Cause(err, "write request") +func (t *TCPTransport) findAndReserveActiveConn() *reuseableDNSConn { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + var bestConn *reuseableDNSConn + var minQueries int32 = -1 + var closedCount int + + for _, conn := range t.activeConns { + select { + case <-conn.done: + closedCount++ + default: + if conn.maxQueries <= 0 || atomic.LoadInt32(&conn.activeQueries) < int32(conn.maxQueries) { + current := atomic.LoadInt32(&conn.activeQueries) + if minQueries == -1 || current < minQueries { + minQueries = current + bestConn = conn + } + } + } + } + + if bestConn != nil && minQueries == 0 && closedCount == 0 { + atomic.AddInt32(&bestConn.activeQueries, 1) + return bestConn + } + + if closedCount > 0 { + validConns := make([]*reuseableDNSConn, 0, len(t.activeConns)-closedCount) + for _, conn := range t.activeConns { + select { + case <-conn.done: + default: + validConns = append(validConns, conn) + } + } + t.activeConns = validConns + } + + if bestConn != nil { + atomic.AddInt32(&bestConn.activeQueries, 1) + } + + return bestConn +} + +func (t *TCPTransport) addActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for _, c := range t.activeConns { + if c == conn { + return + } } - response, err := ReadMessage(conn) + + t.activeConns = append(t.activeConns, conn) +} + +func (t *TCPTransport) removeActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for i, c := range t.activeConns { + if c == conn { + last := len(t.activeConns) - 1 + t.activeConns[i] = t.activeConns[last] + t.activeConns = t.activeConns[:last] + return + } + } +} + +func (t *TCPTransport) markPipelineDetected() bool { + return atomic.CompareAndSwapInt32(&t.pipelineDetected, 0, 1) +} + +func (t *TCPTransport) isPipelineDetected() bool { + return atomic.LoadInt32(&t.pipelineDetected) != 0 +} + +func (t *TCPTransport) getDetectionCounters() (*int32, *int32, *int32) { + return &t.consecutiveOutOfOrder, &t.outOfOrderCount, &t.totalResponses +} + +func (t *TCPTransport) createNewConnection(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + rawConn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) if err != nil { - _ = conn.Close() - return nil, E.Cause(err, "read response") + return nil, E.Cause(err, "dial TCP connection") } - if t.connections != nil { - t.connections.Put(conn) + var connIdleTimeout time.Duration + if t.connections != nil && t.disableKeepAlive { + connIdleTimeout = t.idleTimeout } - return response, nil + conn := newReuseableDNSConn(rawConn, t.logger, t.enablePipeline, connIdleTimeout, t.maxQueries, t.connections, t) + + if t.connections == nil { + defer conn.Close() + } else if t.enablePipeline && t.maxQueries > 0 { + t.addActiveConn(conn) + } + + return conn.Exchange(ctx, message) } func ReadMessage(reader io.Reader) (*mDNS.Msg, error) { @@ -151,3 +329,228 @@ func WriteMessage(writer io.Writer, messageId uint16, message *mDNS.Msg) error { buffer.Truncate(2 + len(rawMessage)) return common.Error(writer.Write(buffer.Bytes())) } + +type dnsCallback struct { + access sync.Mutex + message *mDNS.Msg + done chan struct{} +} + +type reuseableDNSConn struct { + net.Conn + logger logger.ContextLogger + access sync.RWMutex + done chan struct{} + closeOnce sync.Once + err error + queryId uint16 + callbacks map[uint16]*dnsCallback + writeLock sync.Mutex + startReadOnce sync.Once + enablePipeline bool + activeQueries int32 + maxQueries int + pool *expiringpool.ExpiringPool[*reuseableDNSConn] + transport dnsTransportManager + idleTimeout time.Duration + idleTimer *time.Timer +} + +func newReuseableDNSConn(conn net.Conn, logger logger.ContextLogger, enablePipeline bool, idleTimeout time.Duration, maxQueries int, pool *expiringpool.ExpiringPool[*reuseableDNSConn], transport dnsTransportManager) *reuseableDNSConn { + c := &reuseableDNSConn{ + Conn: conn, + logger: logger, + done: make(chan struct{}), + callbacks: make(map[uint16]*dnsCallback), + enablePipeline: enablePipeline, + maxQueries: maxQueries, + pool: pool, + transport: transport, + idleTimeout: idleTimeout, + } + if idleTimeout > 0 { + c.idleTimer = time.AfterFunc(idleTimeout, func() { + c.closeWithError(E.New("connection idle timeout")) + }) + } + return c +} + +func (c *reuseableDNSConn) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + atomic.AddInt32(&c.activeQueries, 1) + return c.exchangeWithCleanup(ctx, message, true) +} + +func (c *reuseableDNSConn) exchangeWithoutIncrement(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + return c.exchangeWithCleanup(ctx, message, true) +} + +func (c *reuseableDNSConn) exchangeWithCleanup(ctx context.Context, message *mDNS.Msg, resetTimer bool) (*mDNS.Msg, error) { + if resetTimer && c.enablePipeline && c.idleTimer != nil { + c.idleTimer.Reset(c.idleTimeout) + } + defer func() { + if resetTimer && !c.enablePipeline && c.idleTimer != nil { + c.idleTimer.Reset(c.idleTimeout) + } + newCount := atomic.AddInt32(&c.activeQueries, -1) + if newCount == 0 && c.pool != nil { + if c.enablePipeline && c.maxQueries > 0 && c.transport != nil { + c.transport.removeActiveConn(c) + } + select { + case <-c.done: + default: + c.pool.Put(c) + } + } + }() + + if !c.enablePipeline { + c.writeLock.Lock() + defer c.writeLock.Unlock() + + err := WriteMessage(c.Conn, 0, message) + if err != nil { + wrappedErr := E.Cause(err, "write request") + c.closeWithError(wrappedErr) + return nil, wrappedErr + } + response, err := ReadMessage(c.Conn) + if err != nil { + wrappedErr := E.Cause(err, "read response") + c.closeWithError(wrappedErr) + return nil, wrappedErr + } + return response, nil + } + + c.startReadOnce.Do(func() { + go c.recvLoop() + }) + + c.access.Lock() + c.queryId++ + messageId := c.queryId + callback := &dnsCallback{ + done: make(chan struct{}), + } + c.callbacks[messageId] = callback + c.access.Unlock() + + defer func() { + c.access.Lock() + delete(c.callbacks, messageId) + c.access.Unlock() + }() + + c.writeLock.Lock() + err := WriteMessage(c.Conn, messageId, message) + c.writeLock.Unlock() + if err != nil { + wrappedErr := E.Cause(err, "write request") + c.closeWithError(wrappedErr) + return nil, wrappedErr + } + originalId := message.Id + select { + case <-callback.done: + if callback.message != nil { + callback.message.Id = originalId + return callback.message, nil + } + return nil, E.New("response is nil") + case <-c.done: + return nil, c.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *reuseableDNSConn) recvLoop() { + var lastRecvId uint16 + for { + message, err := ReadMessage(c.Conn) + if err != nil { + wrappedErr := E.Cause(err, "read response") + c.closeWithError(wrappedErr) + return + } + + c.access.RLock() + callback, loaded := c.callbacks[message.Id] + c.access.RUnlock() + + if !loaded { + if c.logger != nil { + c.logger.Warn("received response for unknown message ID: ", message.Id) + } + continue + } + + if c.enablePipeline && c.transport != nil && !c.transport.isPipelineDetected() { + consecutivePtr, outOfOrderPtr, totalPtr := c.transport.getDetectionCounters() + totalResp := atomic.AddInt32(totalPtr, 1) + + detected := false + if totalResp > 1 { + diff := uint16(message.Id) - uint16(lastRecvId) + if diff > 0x8000 { + outOfOrder := atomic.AddInt32(outOfOrderPtr, 1) + consecutive := atomic.AddInt32(consecutivePtr, 1) + + if consecutive >= 3 || (totalResp >= 10 && outOfOrder*10 > totalResp*3) { + detected = true + if c.transport.markPipelineDetected() && c.logger != nil { + c.logger.Debug("server supports pipelining") + } + } + } else { + atomic.StoreInt32(consecutivePtr, 0) + } + } + + if !detected && totalResp >= 50 { + detected = true + c.transport.markPipelineDetected() + } + + if detected { + atomic.StoreInt32(consecutivePtr, 0) + atomic.StoreInt32(outOfOrderPtr, 0) + atomic.StoreInt32(totalPtr, 0) + } + } + lastRecvId = message.Id + callback.access.Lock() + select { + case <-callback.done: + default: + callback.message = message + close(callback.done) + } + callback.access.Unlock() + } +} + +func (c *reuseableDNSConn) IsOverMaxQueries() bool { + if c.maxQueries <= 0 { + return false + } + return atomic.LoadInt32(&c.activeQueries) >= int32(c.maxQueries) +} + +func (c *reuseableDNSConn) closeWithError(err error) { + c.closeOnce.Do(func() { + if c.idleTimer != nil { + c.idleTimer.Stop() + } + c.err = err + close(c.done) + c.Conn.Close() + }) +} + +func (c *reuseableDNSConn) Close() { + c.closeWithError(net.ErrClosed) +} diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 87bcd7468b..6efa220e13 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -2,12 +2,13 @@ package transport import ( "context" - "net" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/expiringpool" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -18,7 +19,6 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/x/list" mDNS "github.com/miekg/dns" ) @@ -31,17 +31,21 @@ func RegisterTLS(registry *dns.TransportRegistry) { type TLSTransport struct { *BaseTransport - - dialer tls.Dialer - serverAddr M.Socksaddr - tlsConfig tls.Config - access sync.Mutex - connections list.List[*reusableDNSConn] -} - -type reusableDNSConn struct { - net.Conn - queryId uint16 + logger logger.ContextLogger + dialer tls.Dialer + serverAddr M.Socksaddr + tlsConfig tls.Config + connections *expiringpool.ExpiringPool[*reuseableDNSConn] + enablePipeline bool + idleTimeout time.Duration + disableKeepAlive bool + maxQueries int + activeConns []*reuseableDNSConn + activeAccess sync.Mutex + pipelineDetected int32 + consecutiveOutOfOrder int32 + outOfOrderCount int32 + totalResponses int32 } func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { @@ -62,16 +66,49 @@ func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } - return NewTLSRaw(logger, dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTLS, tag, options.RemoteDNSServerOptions), transportDialer, serverAddr, tlsConfig), nil + var poolIdleTimeout time.Duration + if options.DisableTCPKeepAlive { + poolIdleTimeout = 2 * time.Minute + } else { + var keepAliveIdle, keepAliveInterval time.Duration + if options.TCPKeepAlive != 0 { + keepAliveIdle = time.Duration(options.TCPKeepAlive) + } else { + keepAliveIdle = C.TCPKeepAliveInitial + } + if options.TCPKeepAliveInterval != 0 { + keepAliveInterval = time.Duration(options.TCPKeepAliveInterval) + } else { + keepAliveInterval = C.TCPKeepAliveInterval + } + poolIdleTimeout = keepAliveIdle + keepAliveInterval + } + maxQueries := options.MaxQueries + if maxQueries <= 0 { + maxQueries = 0 + } + if !options.Pipeline && maxQueries > 0 { + maxQueries = 0 + } + return NewTLSRaw(ctx, logger, dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTLS, tag, options.RemoteDNSServerOptions), transportDialer, serverAddr, tlsConfig, options.Pipeline, poolIdleTimeout, options.DisableTCPKeepAlive, maxQueries), nil } -func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config) *TLSTransport { - return &TLSTransport{ - BaseTransport: NewBaseTransport(adapter, logger), - dialer: tls.NewDialer(dialer, tlsConfig), - serverAddr: serverAddr, - tlsConfig: tlsConfig, +func NewTLSRaw(ctx context.Context, logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config, enablePipeline bool, idleTimeout time.Duration, disableKeepAlive bool, maxQueries int) *TLSTransport { + transport := &TLSTransport{ + BaseTransport: NewBaseTransport(adapter, logger), + logger: logger, + dialer: tls.NewDialer(dialer, tlsConfig), + serverAddr: serverAddr, + tlsConfig: tlsConfig, + enablePipeline: enablePipeline, + idleTimeout: idleTimeout, + disableKeepAlive: disableKeepAlive, + maxQueries: maxQueries, } + transport.connections = expiringpool.New(ctx, idleTimeout, func(conn *reuseableDNSConn) { + conn.Close() + }) + return transport } func (t *TLSTransport) Start(stage adapter.StartStage) error { @@ -82,26 +119,120 @@ func (t *TLSTransport) Start(stage adapter.StartStage) error { if err != nil { return err } + if t.connections != nil { + t.connections.Start() + } return dialer.InitializeDetour(t.dialer) } func (t *TLSTransport) Close() error { - t.access.Lock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() + if t.connections != nil { + t.connections.Close() } - t.connections.Init() - t.access.Unlock() return t.BaseTransport.Close() } func (t *TLSTransport) Reset() { - t.access.Lock() - defer t.access.Unlock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() +} + +func (t *TLSTransport) getValidConnFromPool() *reuseableDNSConn { + conn := t.connections.Get() + if conn == nil { + return nil + } + + select { + case <-conn.done: + return nil + default: + return conn } - t.connections.Init() +} + +func (t *TLSTransport) findAndReserveActiveConn() *reuseableDNSConn { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + var bestConn *reuseableDNSConn + var minQueries int32 = -1 + var closedCount int + + for _, conn := range t.activeConns { + select { + case <-conn.done: + closedCount++ + default: + if conn.maxQueries <= 0 || atomic.LoadInt32(&conn.activeQueries) < int32(conn.maxQueries) { + current := atomic.LoadInt32(&conn.activeQueries) + if minQueries == -1 || current < minQueries { + minQueries = current + bestConn = conn + } + } + } + } + + if bestConn != nil && minQueries == 0 && closedCount == 0 { + atomic.AddInt32(&bestConn.activeQueries, 1) + return bestConn + } + + if closedCount > 0 { + validConns := make([]*reuseableDNSConn, 0, len(t.activeConns)-closedCount) + for _, conn := range t.activeConns { + select { + case <-conn.done: + default: + validConns = append(validConns, conn) + } + } + t.activeConns = validConns + } + + if bestConn != nil { + atomic.AddInt32(&bestConn.activeQueries, 1) + } + + return bestConn +} + +func (t *TLSTransport) addActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for _, c := range t.activeConns { + if c == conn { + return + } + } + + t.activeConns = append(t.activeConns, conn) +} + +func (t *TLSTransport) removeActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for i, c := range t.activeConns { + if c == conn { + last := len(t.activeConns) - 1 + t.activeConns[i] = t.activeConns[last] + t.activeConns = t.activeConns[:last] + return + } + } +} + +func (t *TLSTransport) markPipelineDetected() bool { + return atomic.CompareAndSwapInt32(&t.pipelineDetected, 0, 1) +} + +func (t *TLSTransport) isPipelineDetected() bool { + return atomic.LoadInt32(&t.pipelineDetected) != 0 +} + +func (t *TLSTransport) getDetectionCounters() (*int32, *int32, *int32) { + return &t.consecutiveOutOfOrder, &t.outOfOrderCount, &t.totalResponses } func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { @@ -110,46 +241,59 @@ func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M } defer t.EndQuery() - t.access.Lock() - conn := t.connections.PopFront() - t.access.Unlock() - if conn != nil { - response, err := t.exchange(ctx, message, conn) - if err == nil { - return response, nil - } - t.Logger.DebugContext(ctx, "discarded pooled connection: ", err) + if t.connections == nil { + return t.createNewConnection(ctx, message) } - tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TLS connection") + + if t.enablePipeline { + if t.maxQueries == 0 { + conn := t.getValidConnFromPool() + if conn != nil { + return conn.Exchange(ctx, message) + } + return t.createNewConnection(ctx, message) + } else { + conn := t.findAndReserveActiveConn() + if conn != nil { + return conn.exchangeWithoutIncrement(ctx, message) + } + + conn = t.getValidConnFromPool() + if conn != nil { + t.addActiveConn(conn) + return conn.Exchange(ctx, message) + } + + return t.createNewConnection(ctx, message) + } + } else { + conn := t.getValidConnFromPool() + if conn != nil { + response, err := conn.Exchange(ctx, message) + if err == nil { + return response, nil + } + } + return t.createNewConnection(ctx, message) } - return t.exchange(ctx, message, &reusableDNSConn{Conn: tlsConn}) } -func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *reusableDNSConn) (*mDNS.Msg, error) { - if deadline, ok := ctx.Deadline(); ok { - conn.SetDeadline(deadline) - } - conn.queryId++ - err := WriteMessage(conn, conn.queryId, message) +func (t *TLSTransport) createNewConnection(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) if err != nil { - conn.Close() - return nil, E.Cause(err, "write request") + return nil, E.Cause(err, "dial TLS connection") } - response, err := ReadMessage(conn) - if err != nil { - conn.Close() - return nil, E.Cause(err, "read response") + var connIdleTimeout time.Duration + if t.connections != nil && t.disableKeepAlive { + connIdleTimeout = t.idleTimeout } - t.access.Lock() - if t.State() >= StateClosing { - t.access.Unlock() - conn.Close() - return response, nil + conn := newReuseableDNSConn(tlsConn, t.logger, t.enablePipeline, connIdleTimeout, t.maxQueries, t.connections, t) + + if t.connections == nil { + defer conn.Close() + } else if t.enablePipeline && t.maxQueries > 0 { + t.addActiveConn(conn) } - conn.SetDeadline(time.Time{}) - t.connections.PushBack(conn) - t.access.Unlock() - return response, nil + + return conn.Exchange(ctx, message) } diff --git a/option/dns.go b/option/dns.go index 6d825df4fa..c770a919f5 100644 --- a/option/dns.go +++ b/option/dns.go @@ -390,12 +390,16 @@ type RemoteDNSServerOptions struct { type RemoteTCPDNSServerOptions struct { RemoteDNSServerOptions - Reuse bool `json:"reuse,omitempty"` + Reuse bool `json:"reuse,omitempty"` + Pipeline bool `json:"pipeline,omitempty"` + MaxQueries int `json:"max_queries,omitempty"` } type RemoteTLSDNSServerOptions struct { RemoteDNSServerOptions OutboundTLSOptionsContainer + Pipeline bool `json:"pipeline,omitempty"` + MaxQueries int `json:"max_queries,omitempty"` } type _RemoteHTTPSDNSServerOptions struct { diff --git a/service/resolved/transport.go b/service/resolved/transport.go index ac20663ae0..275020d5e9 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -143,8 +143,7 @@ func (t *Transport) updateTransports(link *TransportLink) error { Enabled: true, ServerName: serverAddr.String(), })) - transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53), tlsConfig)) - + transports = append(transports, transport.NewTLSRaw(t.ctx, t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53), tlsConfig, false, C.TCPKeepAliveInitial+C.TCPKeepAliveInterval, false, 0)) } else { transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53))) } @@ -165,8 +164,7 @@ func (t *Transport) updateTransports(link *TransportLink) error { Enabled: true, ServerName: serverName, })) - transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port), tlsConfig)) - + transports = append(transports, transport.NewTLSRaw(t.ctx, t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port), tlsConfig, false, C.TCPKeepAliveInitial+C.TCPKeepAliveInterval, false, 0)) } else { transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port))) } From a705e74019da29770b62a579f3a3a64f36788e89 Mon Sep 17 00:00:00 2001 From: lux5am Date: Mon, 9 Jun 2025 07:16:11 +0800 Subject: [PATCH 48/57] add outbound type loadbalance Signed-off-by: lux5am --- adapter/experimental.go | 9 +- adapter/inbound.go | 27 + constant/proxy.go | 7 +- docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/outbound/loadbalance.md | 88 +++ docs/configuration/outbound/loadbalance.zh.md | 88 +++ experimental/clashapi/api_meta_group.go | 2 + .../clashapi/trafficontrol/tracker.go | 90 ++- include/registry.go | 3 +- option/group.go | 10 + protocol/group/loadbalance.go | 659 ++++++++++++++++++ protocol/group/selector.go | 4 +- route/conn.go | 11 +- route/route.go | 2 + 15 files changed, 967 insertions(+), 35 deletions(-) create mode 100644 docs/configuration/outbound/loadbalance.md create mode 100644 docs/configuration/outbound/loadbalance.zh.md create mode 100644 protocol/group/loadbalance.go diff --git a/adapter/experimental.go b/adapter/experimental.go index e6077ebce2..bbcd57e31f 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -169,9 +169,16 @@ type URLTestGroup interface { URLTest(ctx context.Context) (map[string]uint16, error) } +type LoadBalanceGroup interface { + OutboundGroup + URLTest(ctx context.Context) (map[string]uint16, error) +} + func OutboundTag(detour Outbound) string { if group, isGroup := detour.(OutboundGroup); isGroup { - return group.Now() + if now := group.Now(); now != "" { + return now + } } return detour.Tag() } diff --git a/adapter/inbound.go b/adapter/inbound.go index f1c8c1194e..f591145666 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -103,6 +103,32 @@ type InboundContext struct { DestinationPortMatch bool DidMatch bool IgnoreDestinationIPCIDRMatch bool + + // extended metadata + Extended *InboundContextExtended +} + +type InboundContextExtended struct { + RealOutboundChain []string +} + +func (c *InboundContext) InitExtended() { + if c.Extended == nil { + c.Extended = new(InboundContextExtended) + } +} + +func (c *InboundContext) AppendRealOutbound(tag string) { + if c.Extended != nil { + c.Extended.RealOutboundChain = append(c.Extended.RealOutboundChain, tag) + } +} + +func (c *InboundContext) GetRealOutboundChain() []string { + if c.Extended != nil { + return c.Extended.RealOutboundChain + } + return nil } func (c *InboundContext) ResetRuleCache() { @@ -118,6 +144,7 @@ func (c *InboundContext) ResetRuleCache() { type inboundContextKey struct{} func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { + inboundContext.InitExtended() return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext) } diff --git a/constant/proxy.go b/constant/proxy.go index 278a46c2f6..38b725ac4d 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -34,8 +34,9 @@ const ( ) const ( - TypeSelector = "selector" - TypeURLTest = "urltest" + TypeSelector = "selector" + TypeURLTest = "urltest" + TypeLoadBalance = "loadbalance" ) func ProxyDisplayName(proxyType string) string { @@ -92,6 +93,8 @@ func ProxyDisplayName(proxyType string) string { return "Selector" case TypeURLTest: return "URLTest" + case TypeLoadBalance: + return "LoadBalance" default: return "Unknown" } diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 47b8a96a5c..3998f7e744 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -37,6 +37,7 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `loadbalance` | [LoadBalance](./loadbalance/) | #### tag diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index a1c4a7addc..73278459a0 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -37,6 +37,7 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `loadbalance` | [LoadBalance](./loadbalance/) | #### tag diff --git a/docs/configuration/outbound/loadbalance.md b/docs/configuration/outbound/loadbalance.md new file mode 100644 index 0000000000..b6a3f758d8 --- /dev/null +++ b/docs/configuration/outbound/loadbalance.md @@ -0,0 +1,88 @@ +### Structure + +```json +{ + "type": "loadbalance", + "tag": "balance", + "strategy": "round-robin", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### strategy + +Load Balancing Strategies. + +* `round-robin` will distribute all requests among different proxy nodes within the strategy group. + +* `consistent-hashing` will assign requests with the same `target address` to the same proxy node within the strategy group. + +* `sticky-sessions`: requests with the same `source address` and `target address` will be directed to the same proxy node within the strategy group, with a cache expiration of specified ttl. + +!!! note + When the `target address` is a domain, it uses top-level domain matching. + +#### outbounds + +List of outbound tags to test. + +#### providers + +List of [Provider](/configuration/provider) tags to test. + +#### exclude + +Exclude regular expression to filter `providers` nodes. + +#### include + +Include regular expression to filter `providers` nodes. + +#### url + +The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. + +#### interval + +The test interval. `3m` will be used if empty. + +#### idle_timeout + +The idle timeout. `30m` will be used if empty. + +#### ttl + +The time to live used for `sticky-sessions` strategy timeout. `10m` will be used if empty. + +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + +#### interrupt_exist_connections + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. diff --git a/docs/configuration/outbound/loadbalance.zh.md b/docs/configuration/outbound/loadbalance.zh.md new file mode 100644 index 0000000000..c8bab73ec8 --- /dev/null +++ b/docs/configuration/outbound/loadbalance.zh.md @@ -0,0 +1,88 @@ +### 结构 + +```json +{ + "type": "loadbalance", + "tag": "balance", + "strategy": "round-robin", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### 字段 + +#### strategy + +负载均衡策略。 + +* `round-robin` 将在策略组内的不同代理节点之间分配所有请求。 + +* `consistent-hashing` 将具有相同 `目标地址` 的请求分配给策略组内的同一代理节点。 + +* `sticky-sessions`:具有相同 `源地址` 和 `目标地址` 的请求将被导向策略组内的同一代理节点,缓存过期时间为指定的 ttl。 + +!!! note + 当 `目标地址` 是域名时,使用顶级域名匹配。 + +#### outbounds + +用于测试的出站标签列表。 + +#### providers + +用于测试的[订阅](/zh/configuration/provider)标签列表。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + +#### url + +用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 + +#### interval + +测试间隔。 默认使用 `3m`。 + +#### idle_timeout + +空闲超时。默认使用 `30m`。 + +#### ttl + +用于 `sticky-sessions` 策略超时的生存时间。默认使用 `10m`。 + +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + +#### interrupt_exist_connections + +当选定的出站发生更改时,中断现有连接。 + +仅入站连接受此设置影响,内部连接将始终被中断。 diff --git a/experimental/clashapi/api_meta_group.go b/experimental/clashapi/api_meta_group.go index 31dbdaf692..17a8eea929 100644 --- a/experimental/clashapi/api_meta_group.go +++ b/experimental/clashapi/api_meta_group.go @@ -84,6 +84,8 @@ func getGroupDelay(server *Server) func(w http.ResponseWriter, r *http.Request) var result map[string]uint16 if urlTestGroup, isURLTestGroup := outboundGroup.(adapter.URLTestGroup); isURLTestGroup { result, err = urlTestGroup.URLTest(ctx) + } else if loadBalanceGroup, isLoadBalanceGroup := outboundGroup.(adapter.LoadBalanceGroup); isLoadBalanceGroup { + result, err = loadBalanceGroup.URLTest(ctx) } else { outbounds := common.FilterNotNil(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { itOutbound, _ := server.outbound.Outbound(it) diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go index c6640812a2..089e1480d2 100644 --- a/experimental/clashapi/trafficontrol/tracker.go +++ b/experimental/clashapi/trafficontrol/tracker.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" F "github.com/sagernet/sing/common/format" @@ -17,16 +18,17 @@ import ( ) type TrackerMetadata struct { - ID uuid.UUID - Metadata adapter.InboundContext - CreatedAt time.Time - ClosedAt time.Time - Upload *atomic.Int64 - Download *atomic.Int64 - Chain []string - Rule adapter.Rule - Outbound string - OutboundType string + ID uuid.UUID + Metadata adapter.InboundContext + CreatedAt time.Time + ClosedAt time.Time + Upload *atomic.Int64 + Download *atomic.Int64 + Chain []string + Rule adapter.Rule + Outbound string + OutboundType string + outboundManager adapter.OutboundManager } func (t TrackerMetadata) MarshalJSON() ([]byte, error) { @@ -71,6 +73,34 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { } else { rule = "final" } + chains := t.Chain + if t.OutboundType == C.TypeLoadBalance { + realOutboundChain := t.Metadata.GetRealOutboundChain() + if len(realOutboundChain) > 0 && t.outboundManager != nil { + var subChain []string + for _, realOutbound := range realOutboundChain { + next := realOutbound + for { + detour, loaded := t.outboundManager.Outbound(next) + if !loaded { + break + } + subChain = append(subChain, next) + group, isGroup := detour.(adapter.OutboundGroup) + if !isGroup { + break + } + next = group.Now() + if next == "" { + break + } + } + } + chains = make([]string, len(subChain)+len(t.Chain)) + copy(chains, common.Reverse(subChain)) + copy(chains[len(subChain):], t.Chain) + } + } return json.Marshal(map[string]any{ "id": t.ID, "metadata": map[string]any{ @@ -88,7 +118,7 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { "upload": t.Upload.Load(), "download": t.Download.Load(), "start": t.CreatedAt, - "chains": t.Chain, + "chains": chains, "rule": rule, "rulePayload": "", }) @@ -164,15 +194,16 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata adapter.InboundCont manager.PushDownloaded(n) }}), metadata: TrackerMetadata{ - ID: id, - Metadata: metadata, - CreatedAt: time.Now(), - Upload: upload, - Download: download, - Chain: common.Reverse(chain), - Rule: matchRule, - Outbound: outbound, - OutboundType: outboundType, + ID: id, + Metadata: metadata, + CreatedAt: time.Now(), + Upload: upload, + Download: download, + Chain: common.Reverse(chain), + Rule: matchRule, + Outbound: outbound, + OutboundType: outboundType, + outboundManager: outboundManager, }, manager: manager, } @@ -245,15 +276,16 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata adapter.Inbound manager.PushDownloaded(n) }}), metadata: TrackerMetadata{ - ID: id, - Metadata: metadata, - CreatedAt: time.Now(), - Upload: upload, - Download: download, - Chain: common.Reverse(chain), - Rule: matchRule, - Outbound: outbound, - OutboundType: outboundType, + ID: id, + Metadata: metadata, + CreatedAt: time.Now(), + Upload: upload, + Download: download, + Chain: common.Reverse(chain), + Rule: matchRule, + Outbound: outbound, + OutboundType: outboundType, + outboundManager: outboundManager, }, manager: manager, } diff --git a/include/registry.go b/include/registry.go index 4db653949f..baae4707a0 100644 --- a/include/registry.go +++ b/include/registry.go @@ -3,7 +3,7 @@ package include import ( "context" - "github.com/sagernet/sing-box" + box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" @@ -91,6 +91,7 @@ func OutboundRegistry() *outbound.Registry { group.RegisterSelector(registry) group.RegisterURLTest(registry) + group.RegisterLoadBalance(registry) socks.RegisterOutbound(registry) http.RegisterOutbound(registry) diff --git a/option/group.go b/option/group.go index df7aea556c..dd47168ce4 100644 --- a/option/group.go +++ b/option/group.go @@ -30,3 +30,13 @@ type URLTestFallbackOptions struct { Enabled bool `json:"enabled,omitempty"` MaxDelay badoption.Duration `json:"max_delay,omitempty"` } + +type LoadBalanceOutboundOptions struct { + GroupCommonOption + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + TTL badoption.Duration `json:"ttl,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + Strategy string `json:"strategy,omitempty"` +} diff --git a/protocol/group/loadbalance.go b/protocol/group/loadbalance.go new file mode 100644 index 0000000000..deda184a37 --- /dev/null +++ b/protocol/group/loadbalance.go @@ -0,0 +1,659 @@ +package group + +import ( + "context" + "fmt" + "net" + "net/netip" + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/interrupt" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/batch" + 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/common/x/list" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" + + "golang.org/x/net/publicsuffix" +) + +func RegisterLoadBalance(registry *outbound.Registry) { + outbound.Register[option.LoadBalanceOutboundOptions](registry, C.TypeLoadBalance, NewLoadBalance) +} + +var _ adapter.OutboundGroup = (*LoadBalance)(nil) + +const ( + StrategyRoundRobin = "round-robin" + StrategyConsistentHashing = "consistent-hashing" + StrategyStickySessions = "sticky-sessions" +) + +type LoadBalance struct { + outbound.Adapter + ctx context.Context + router adapter.Router + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger log.ContextLogger + tags []string + link string + interval time.Duration + idleTimeout time.Duration + ttl time.Duration + group *LoadBalanceGroup + interruptExternalConnections bool + strategy string + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + cancel context.CancelFunc + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool +} + +func NewLoadBalance(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.LoadBalanceOutboundOptions) (adapter.Outbound, error) { + strategy := options.Strategy + if strategy == "" { + strategy = StrategyRoundRobin + } + switch strategy { + case StrategyRoundRobin, StrategyConsistentHashing, StrategyStickySessions: + default: + return nil, E.New("load-balance strategy not found: ", strategy) + } + outbound := &LoadBalance{ + Adapter: outbound.NewAdapter(C.TypeLoadBalance, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + ctx: ctx, + router: router, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + tags: options.Outbounds, + link: options.URL, + interval: time.Duration(options.Interval), + ttl: time.Duration(options.TTL), + idleTimeout: time.Duration(options.IdleTimeout), + interruptExternalConnections: options.InterruptExistConnections, + strategy: strategy, + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, + } + return outbound, nil +} + +func (s *LoadBalance) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + + outbounds := make([]adapter.Outbound, 0, len(s.tags)) + for i, tag := range s.tags { + detour, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + outbounds = append(outbounds, detour) + } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + group, err := NewLoadBalanceGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.idleTimeout, s.ttl, s.interruptExternalConnections, s.strategy) + if err != nil { + return err + } + s.group = group + return nil +} + +func (s *LoadBalance) PostStart() error { + s.group.PostStart() + return nil +} + +func (s *LoadBalance) Close() error { + return common.Close( + common.PtrOrNil(s.group), + ) +} + +func (s *LoadBalance) Now() string { + return "" +} + +func (s *LoadBalance) All() []string { + var all []string + for _, outbound := range s.group.outbounds { + all = append(all, outbound.Tag()) + } + return all +} + +func (s *LoadBalance) URLTest(ctx context.Context) (map[string]uint16, error) { + return s.group.URLTest(ctx) +} + +func (s *LoadBalance) CheckOutbounds() { + s.group.CheckOutbounds(true) +} + +func (s *LoadBalance) isGroupActive() bool { + if !s.group.started { + return false + } + return time.Since(s.group.lastActive.Load()) <= s.group.idleTimeout +} + +func (s *LoadBalance) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + s.group.Touch() + metadata := adapter.ContextFrom(ctx) + outbound := s.group.Unwrap(metadata, true) + if outbound == nil || !common.Contains(outbound.Network(), network) { + return nil, E.New("missing supported outbound") + } + if metadata != nil { + metadata.AppendRealOutbound(outbound.Tag()) + } + conn, err := outbound.DialContext(ctx, network, destination) + if err == nil { + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil + } + s.logger.ErrorContext(ctx, err) + s.group.history.DeleteURLTestHistory(outbound.Tag()) + return nil, err +} + +func (s *LoadBalance) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + s.group.Touch() + metadata := adapter.ContextFrom(ctx) + outbound := s.group.Unwrap(metadata, true) + if outbound == nil || !common.Contains(outbound.Network(), N.NetworkUDP) { + return nil, E.New("missing supported outbound") + } + if metadata != nil { + metadata.AppendRealOutbound(outbound.Tag()) + } + conn, err := outbound.ListenPacket(ctx, destination) + if err == nil { + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil + } + s.logger.ErrorContext(ctx, err) + s.group.history.DeleteURLTestHistory(outbound.Tag()) + return nil, err +} + +func (s *LoadBalance) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + s.connection.NewConnection(ctx, s, conn, metadata, onClose) +} + +func (s *LoadBalance) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose) +} + +func (s *LoadBalance) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + s.group.Touch() + selected := s.group.Unwrap(&metadata, true) + if selected == nil { + return nil, E.New("missing supported outbound") + } + if !common.Contains(selected.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag()) + } + return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) +} + +func (s *LoadBalance) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New("outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outbounds []adapter.Outbound + ) + for _, tag := range tags { + detour, _ := s.outbound.Outbound(tag) + outbounds = append(outbounds, detour) + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + } + outbounds = append(outbounds, cache...) + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + s.tags, s.group.outbounds = tags, outbounds + if s.isGroupActive() { + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) + } + return nil +} + +type strategyFn = func(metadata *adapter.InboundContext, touch bool) adapter.Outbound + +type LoadBalanceGroup struct { + ctx context.Context + router adapter.Router + outbound adapter.OutboundManager + pause pause.Manager + pauseCallback *list.Element[pause.Callback] + logger log.Logger + outbounds []adapter.Outbound + link string + interval time.Duration + idleTimeout time.Duration + ttl time.Duration + history adapter.URLTestHistoryStorage + checking atomic.Bool + interruptGroup *interrupt.Group + interruptExternalConnections bool + access sync.Mutex + ticker *time.Ticker + close chan struct{} + started bool + lastActive common.TypedValue[time.Time] + strategyFn strategyFn +} + +func NewLoadBalanceGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, idleTimeout time.Duration, ttl time.Duration, interruptExternalConnections bool, strategy string) (*LoadBalanceGroup, error) { + if interval == 0 { + interval = C.DefaultURLTestInterval + } + if idleTimeout == 0 { + idleTimeout = C.DefaultURLTestIdleTimeout + } + if interval > idleTimeout { + return nil, E.New("interval must be less or equal than idle_timeout") + } + if ttl == 0 { + ttl = time.Minute * 10 + } + var history adapter.URLTestHistoryStorage + if historyFromCtx := service.PtrFromContext[urltest.HistoryStorage](ctx); historyFromCtx != nil { + history = historyFromCtx + } else if clashServer := service.FromContext[adapter.ClashServer](ctx); clashServer != nil { + history = clashServer.HistoryStorage() + } else { + history = urltest.NewHistoryStorage() + } + if link == "" { + link = "https://www.gstatic.com/generate_204" + } + loadBalanceGroup := &LoadBalanceGroup{ + ctx: ctx, + outbound: outboundManager, + logger: logger, + outbounds: outbounds, + link: link, + interval: interval, + idleTimeout: idleTimeout, + ttl: ttl, + history: history, + close: make(chan struct{}), + pause: service.FromContext[pause.Manager](ctx), + interruptGroup: interrupt.NewGroup(), + interruptExternalConnections: interruptExternalConnections, + } + switch strategy { + case StrategyRoundRobin: + loadBalanceGroup.strategyFn = strategyRoundRobin(loadBalanceGroup, link) + case StrategyConsistentHashing: + loadBalanceGroup.strategyFn = strategyConsistentHashing(loadBalanceGroup, link) + case StrategyStickySessions: + loadBalanceGroup.strategyFn = strategyStickySessions(loadBalanceGroup, link) + } + return loadBalanceGroup, nil +} + +func (g *LoadBalanceGroup) PostStart() { + g.access.Lock() + defer g.access.Unlock() + g.started = true + g.lastActive.Store(time.Now()) + go g.CheckOutbounds(false) +} + +func (g *LoadBalanceGroup) Touch() { + if !g.started { + return + } + g.access.Lock() + defer g.access.Unlock() + if g.ticker != nil { + g.lastActive.Store(time.Now()) + return + } + g.ticker = time.NewTicker(g.interval) + go g.loopCheck() + g.pauseCallback = pause.RegisterTicker(g.pause, g.ticker, g.interval, nil) +} + +func (g *LoadBalanceGroup) Close() error { + g.access.Lock() + defer g.access.Unlock() + if g.ticker == nil { + return nil + } + g.ticker.Stop() + g.pause.UnregisterCallback(g.pauseCallback) + close(g.close) + return nil +} + +func (g *LoadBalanceGroup) loopCheck() { + if time.Since(g.lastActive.Load()) > g.interval { + g.lastActive.Store(time.Now()) + g.CheckOutbounds(false) + } + for { + select { + case <-g.close: + return + case <-g.ticker.C: + } + if time.Since(g.lastActive.Load()) > g.idleTimeout { + g.access.Lock() + g.ticker.Stop() + g.ticker = nil + g.pause.UnregisterCallback(g.pauseCallback) + g.pauseCallback = nil + g.access.Unlock() + return + } + g.CheckOutbounds(false) + } +} + +func (g *LoadBalanceGroup) CheckOutbounds(force bool) { + _, _ = g.urlTest(g.ctx, force) +} + +func (g *LoadBalanceGroup) URLTest(ctx context.Context) (map[string]uint16, error) { + return g.urlTest(ctx, false) +} + +func (g *LoadBalanceGroup) urlTest(ctx context.Context, force bool) (map[string]uint16, error) { + result := make(map[string]uint16) + if g.checking.Swap(true) { + return result, nil + } + defer g.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + checked := make(map[string]bool) + var resultAccess sync.Mutex + for _, detour := range g.outbounds { + tag := detour.Tag() + realTag := RealTag(detour) + if checked[realTag] { + continue + } + history := g.history.LoadURLTestHistory(realTag) + if !force && history != nil && time.Since(history.Time) < g.interval { + continue + } + checked[realTag] = true + p, loaded := g.outbound.Outbound(realTag) + if !loaded { + continue + } + b.Go(realTag, func() (any, error) { + testCtx, cancel := context.WithTimeout(g.ctx, C.TCPTimeout) + defer cancel() + t, err := urltest.URLTest(testCtx, g.link, p) + if err != nil { + g.logger.Debug("outbound ", tag, " unavailable: ", err) + g.history.DeleteURLTestHistory(realTag) + } else { + g.logger.Debug("outbound ", tag, " available: ", t, "ms") + g.history.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + return result, nil +} + +func (g *LoadBalanceGroup) Unwrap(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + return g.strategyFn(metadata, touch) +} + +func (g *LoadBalanceGroup) AliveForTestUrl(proxy adapter.Outbound) bool { + if history := g.history.LoadURLTestHistory(RealTag(proxy)); history != nil { + return true + } + return false +} + +func getKey(metadata *adapter.InboundContext) string { + if metadata == nil { + return "" + } + + var metadataHost string + if metadata.Destination.IsFqdn() { + metadataHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + metadataHost = metadata.SniffHost + } else { + metadataHost = metadata.Domain + } + + if metadataHost != "" { + // ip host + if ip := net.ParseIP(metadataHost); ip != nil { + return metadataHost + } + + if etld, err := publicsuffix.EffectiveTLDPlusOne(metadataHost); err == nil { + return etld + } + } + + var destinationAddr netip.Addr + if len(metadata.DestinationAddresses) > 0 { + destinationAddr = metadata.DestinationAddresses[0] + } else { + destinationAddr = metadata.Destination.Addr + } + + if !destinationAddr.IsValid() { + return "" + } + + return destinationAddr.String() +} + +func getKeyWithSrcAndDst(metadata *adapter.InboundContext) string { + dst := getKey(metadata) + src := "" + if metadata != nil { + src = metadata.Source.Addr.String() + } + + return fmt.Sprintf("%s%s", src, dst) +} + +func jumpHash(key uint64, buckets int32) int32 { + var b, j int64 + + for j < int64(buckets) { + b = j + key = key*2862933555777941757 + 1 + j = int64(float64(b+1) * (float64(int64(1)<<31) / float64((key>>33)+1))) + } + + return int32(b) +} + +func strategyRoundRobin(g *LoadBalanceGroup, url string) strategyFn { + idx := 0 + idxMutex := sync.Mutex{} + return func(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + idxMutex.Lock() + defer idxMutex.Unlock() + + i := 0 + length := len(g.outbounds) + + if touch { + defer func() { + idx = (idx + i) % length + }() + } + + for ; i < length; i++ { + id := (idx + i) % length + proxy := g.outbounds[id] + if g.AliveForTestUrl(proxy) { + i++ + return proxy + } + } + + return g.outbounds[0] + } +} + +func strategyConsistentHashing(g *LoadBalanceGroup, url string) strategyFn { + maxRetry := 5 + hash := maphash.NewHasher[string]() + return func(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + key := hash.Hash(getKey(metadata)) + buckets := int32(len(g.outbounds)) + for i := 0; i < maxRetry; i, key = i+1, key+1 { + idx := jumpHash(key, buckets) + proxy := g.outbounds[idx] + if g.AliveForTestUrl(proxy) { + return proxy + } + } + + // when availability is poor, traverse the entire list to get the available nodes + for _, proxy := range g.outbounds { + if g.AliveForTestUrl(proxy) { + return proxy + } + } + + return g.outbounds[0] + } +} + +func strategyStickySessions(g *LoadBalanceGroup, url string) strategyFn { + maxRetry := 5 + lruCache := common.Must1(freelru.NewSharded[uint64, int](1000, maphash.NewHasher[uint64]().Hash32)) + lruCache.SetLifetime(g.ttl) + hash := maphash.NewHasher[string]() + return func(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + key := hash.Hash(getKeyWithSrcAndDst(metadata)) + length := len(g.outbounds) + idx, has := lruCache.Get(key) + if !has || idx >= length { + idx = int(jumpHash(key+uint64(time.Now().UnixNano()), int32(length))) + } + + nowIdx := idx + for i := 1; i < maxRetry; i++ { + proxy := g.outbounds[nowIdx] + if g.AliveForTestUrl(proxy) { + if !has || nowIdx != idx { + lruCache.Add(key, nowIdx) + } + + return proxy + } else { + nowIdx = int(jumpHash(key+uint64(time.Now().UnixNano()), int32(length))) + } + } + + lruCache.Add(key, 0) + return g.outbounds[0] + } +} diff --git a/protocol/group/selector.go b/protocol/group/selector.go index ef7d6a5280..53bbac2d43 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -209,7 +209,9 @@ func (s *Selector) NewDirectRouteConnection(metadata adapter.InboundContext, rou func RealTag(detour adapter.Outbound) string { if group, isGroup := detour.(adapter.OutboundGroup); isGroup { - return group.Now() + if now := group.Now(); now != "" { + return now + } } return detour.Tag() } diff --git a/route/conn.go b/route/conn.go index 59afe5394c..e833f79c16 100644 --- a/route/conn.go +++ b/route/conn.go @@ -13,7 +13,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" @@ -111,6 +111,9 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co var dialerString string if outbound, isOutbound := this.(adapter.Outbound); isOutbound { dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + if outbound.Type() == C.TypeLoadBalance { + dialerString += "[" + strings.Join(metadata.GetRealOutboundChain(), " -> ") + "]" + } } err = E.Cause(err, "open connection to ", remoteString, dialerString) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -174,6 +177,9 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial var dialerString string if outbound, isOutbound := this.(adapter.Outbound); isOutbound { dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + if outbound.Type() == C.TypeLoadBalance { + dialerString += "[" + strings.Join(metadata.GetRealOutboundChain(), " -> ") + "]" + } } err = E.Cause(err, "open packet connection to ", remoteString, dialerString) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -197,6 +203,9 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial var dialerString string if outbound, isOutbound := this.(adapter.Outbound); isOutbound { dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + if outbound.Type() == C.TypeLoadBalance { + dialerString += "[" + strings.Join(metadata.GetRealOutboundChain(), " -> ") + "]" + } } err = E.Cause(err, "listen packet connection using ", dialerString) N.CloseOnHandshakeFailure(conn, onClose, err) diff --git a/route/route.go b/route/route.go index 9fc2c481b4..8c4bc4ba7b 100644 --- a/route/route.go +++ b/route/route.go @@ -150,6 +150,7 @@ func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata ad for _, buffer := range buffers { conn = bufio.NewCachedConn(conn, buffer) } + metadata.InitExtended() for _, tracker := range r.trackers { conn = tracker.RoutedConnection(ctx, conn, metadata, selectedRule, selectedOutbound) } @@ -276,6 +277,7 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m conn = bufio.NewCachedPacketConn(conn, buffer.Buffer, buffer.Destination) N.PutPacketBuffer(buffer) } + metadata.InitExtended() for _, tracker := range r.trackers { conn = tracker.RoutedPacketConnection(ctx, conn, metadata, selectedRule, selectedOutbound) } From 8f39d17b95313b0f423b3b41a35ce68d4269acc2 Mon Sep 17 00:00:00 2001 From: lux5am Date: Wed, 18 Dec 2024 11:31:48 +0800 Subject: [PATCH 49/57] add outbound type pass Signed-off-by: lux5am --- adapter/experimental.go | 4 ++++ constant/proxy.go | 3 +++ include/registry.go | 2 ++ protocol/group/selector.go | 4 ++++ protocol/pass/outbound.go | 40 +++++++++++++++++++++++++++++++++++++ provider/parser/sing_box.go | 2 +- route/route.go | 8 ++++++++ 7 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 protocol/pass/outbound.go diff --git a/adapter/experimental.go b/adapter/experimental.go index bbcd57e31f..e214cc53c4 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -174,6 +174,10 @@ type LoadBalanceGroup interface { URLTest(ctx context.Context) (map[string]uint16, error) } +type SelectorGroup interface { + Selected() Outbound +} + func OutboundTag(detour Outbound) string { if group, isGroup := detour.(OutboundGroup); isGroup { if now := group.Now(); now != "" { diff --git a/constant/proxy.go b/constant/proxy.go index 38b725ac4d..23d7668d79 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -6,6 +6,7 @@ const ( TypeTProxy = "tproxy" TypeDirect = "direct" TypeBlock = "block" + TypePass = "pass" TypeDNS = "dns" TypeSOCKS = "socks" TypeHTTP = "http" @@ -51,6 +52,8 @@ func ProxyDisplayName(proxyType string) string { return "Direct" case TypeBlock: return "Block" + case TypePass: + return "Pass" case TypeDNS: return "DNS" case TypeSOCKS: diff --git a/include/registry.go b/include/registry.go index baae4707a0..e2e9b403cc 100644 --- a/include/registry.go +++ b/include/registry.go @@ -25,6 +25,7 @@ import ( "github.com/sagernet/sing-box/protocol/http" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-box/protocol/pass" "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" "github.com/sagernet/sing-box/protocol/shadowtls" @@ -87,6 +88,7 @@ func OutboundRegistry() *outbound.Registry { direct.RegisterOutbound(registry) + pass.RegisterOutbound(registry) block.RegisterOutbound(registry) group.RegisterSelector(registry) diff --git a/protocol/group/selector.go b/protocol/group/selector.go index 53bbac2d43..efd7a4f6ab 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -142,6 +142,10 @@ func (s *Selector) All() []string { return s.tags } +func (s *Selector) Selected() adapter.Outbound { + return s.selected.Load() +} + func (s *Selector) SelectOutbound(tag string) bool { detour, loaded := s.outbounds[tag] if !loaded { diff --git a/protocol/pass/outbound.go b/protocol/pass/outbound.go new file mode 100644 index 0000000000..1946904545 --- /dev/null +++ b/protocol/pass/outbound.go @@ -0,0 +1,40 @@ +package pass + +import ( + "context" + "net" + "syscall" + + "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/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.StubOptions](registry, C.TypePass, New) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger +} + +func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, _ option.StubOptions) (adapter.Outbound, error) { + return &Outbound{ + Adapter: outbound.NewAdapter(C.TypePass, tag, []string{N.NetworkTCP, N.NetworkUDP}, nil), + logger: logger, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return nil, syscall.EPERM +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, syscall.EPERM +} diff --git a/provider/parser/sing_box.go b/provider/parser/sing_box.go index 0883a646e8..c891f3f9d7 100644 --- a/provider/parser/sing_box.go +++ b/provider/parser/sing_box.go @@ -32,7 +32,7 @@ func (o *SingBoxDocument) UnmarshalJSONContext(ctx context.Context, inputContent return E.New("missing type in outbound[", i, "]") } switch typeVal.(string) { - case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest: + case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest, C.TypePass: continue default: outs = append(outs, outbound) diff --git a/route/route.go b/route/route.go index 8c4bc4ba7b..ac7019ec07 100644 --- a/route/route.go +++ b/route/route.go @@ -513,6 +513,14 @@ match: var routeOptions *R.RuleActionRouteOptions switch action := currentRule.Action().(type) { case *R.RuleActionRoute: + if selectedOutbound, loaded := r.outbound.Outbound(action.Outbound); loaded { + if selectedOutbound.Type() == C.TypeSelector { + selectedOutbound = selectedOutbound.(adapter.SelectorGroup).Selected() + } + if selectedOutbound.Type() == C.TypePass { + continue + } + } routeOptions = &action.RuleActionRouteOptions case *R.RuleActionRouteOptions: routeOptions = action From ee4c25b10ee14792e5f61d912c13f074e694db68 Mon Sep 17 00:00:00 2001 From: lux5am Date: Tue, 21 Jan 2025 21:07:14 +0800 Subject: [PATCH 50/57] add `urltest_unified_delay` in experimental config Signed-off-by: lux5am --- box.go | 2 ++ common/urltest/urltest.go | 11 +++++++++++ constant/network.go | 2 ++ option/experimental.go | 9 +++++---- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/box.go b/box.go index af3b027f66..e291bcb94b 100644 --- a/box.go +++ b/box.go @@ -172,6 +172,8 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "create log factory") } + C.URLTestUnifiedDelay = experimentalOptions.URLTestUnifiedDelay + var internalServices []adapter.LifecycleService certificateOptions := common.PtrValueOrDefault(options.Certificate) if C.IsAndroid || certificateOptions.Store != "" && certificateOptions.Store != C.CertificateStoreSystem || diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 29d790e4d0..856449a245 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -125,6 +125,17 @@ func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err e return } resp.Body.Close() + if C.URLTestUnifiedDelay { + second := time.Now() + var ignoredErr error + var secondResp *http.Response + secondResp, ignoredErr = client.Do(req.WithContext(ctx)) + if ignoredErr == nil { + resp = secondResp + resp.Body.Close() + start = second + } + } t = uint16(time.Since(start) / time.Millisecond) return } diff --git a/constant/network.go b/constant/network.go index 88a1dd815f..ce0b7f6917 100644 --- a/constant/network.go +++ b/constant/network.go @@ -5,6 +5,8 @@ import ( F "github.com/sagernet/sing/common/format" ) +var URLTestUnifiedDelay = false + type InterfaceType uint8 const ( diff --git a/option/experimental.go b/option/experimental.go index 518568b909..7fcc6b99d2 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -3,10 +3,11 @@ package option import "github.com/sagernet/sing/common/json/badoption" type ExperimentalOptions struct { - CacheFile *CacheFileOptions `json:"cache_file,omitempty"` - ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` - V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` - Debug *DebugOptions `json:"debug,omitempty"` + CacheFile *CacheFileOptions `json:"cache_file,omitempty"` + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` + V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + Debug *DebugOptions `json:"debug,omitempty"` + URLTestUnifiedDelay bool `json:"urltest_unified_delay,omitempty"` } type CacheFileOptions struct { From 7a372748c45ca7105556d68d77f3bd7e6572e647 Mon Sep 17 00:00:00 2001 From: lux5am Date: Mon, 3 Feb 2025 22:13:31 +0800 Subject: [PATCH 51/57] add `min_cache_ttl` and `max_cache_ttl` in dns config Signed-off-by: lux5am --- dns/client.go | 18 ++++++++++++++++++ dns/router.go | 2 ++ docs/configuration/dns/index.md | 10 ++++++++++ option/dns.go | 2 ++ 4 files changed, 32 insertions(+) diff --git a/dns/client.go b/dns/client.go index ed4e8207b3..2a767eece4 100644 --- a/dns/client.go +++ b/dns/client.go @@ -36,6 +36,8 @@ type Client struct { disableCache bool disableExpire bool independentCache bool + minCacheTTL uint32 + maxCacheTTL uint32 clientSubnet netip.Prefix rdrc adapter.RDRCStore initRDRCFunc func() adapter.RDRCStore @@ -53,6 +55,8 @@ type ClientOptions struct { IndependentCache bool CacheCapacity uint32 ClientSubnet netip.Prefix + MinCacheTTL uint32 + MaxCacheTTL uint32 RDRC func() adapter.RDRCStore Logger logger.ContextLogger } @@ -64,9 +68,17 @@ func NewClient(options ClientOptions) *Client { disableExpire: options.DisableExpire, independentCache: options.IndependentCache, clientSubnet: options.ClientSubnet, + minCacheTTL: options.MinCacheTTL, + maxCacheTTL: options.MaxCacheTTL, initRDRCFunc: options.RDRC, logger: options.Logger, } + if client.maxCacheTTL == 0 { + client.maxCacheTTL = 86400 + } + if client.minCacheTTL > client.maxCacheTTL { + client.maxCacheTTL = client.minCacheTTL + } if client.timeout == 0 { client.timeout = C.DNSTimeout } @@ -289,6 +301,12 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } } } + if timeToLive < c.minCacheTTL { + timeToLive = c.minCacheTTL + } + if timeToLive > c.maxCacheTTL { + timeToLive = c.maxCacheTTL + } if options.RewriteTTL != nil { timeToLive = *options.RewriteTTL } diff --git a/dns/router.go b/dns/router.go index 6e874318ae..9ad24f42bc 100644 --- a/dns/router.go +++ b/dns/router.go @@ -57,6 +57,8 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp IndependentCache: options.DNSClientOptions.IndependentCache, CacheCapacity: options.DNSClientOptions.CacheCapacity, ClientSubnet: options.DNSClientOptions.ClientSubnet.Build(netip.Prefix{}), + MinCacheTTL: options.DNSClientOptions.MinCacheTTL, + MaxCacheTTL: options.DNSClientOptions.MaxCacheTTL, RDRC: func() adapter.RDRCStore { cacheFile := service.FromContext[adapter.CacheFile](ctx) if cacheFile == nil { diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md index c6750a01bb..6068b2a7fd 100644 --- a/docs/configuration/dns/index.md +++ b/docs/configuration/dns/index.md @@ -25,6 +25,8 @@ icon: material/alert-decagram "disable_expire": false, "independent_cache": false, "cache_capacity": 0, + "min_cache_ttl": 0, + "max_cache_ttl": 0, "reverse_mapping": false, "client_subnet": "", "fakeip": {} @@ -73,6 +75,14 @@ LRU cache capacity. Value less than 1024 will be ignored. +#### min_cache_ttl + +Extend short TTL values to the time given when caching them. + +#### max_cache_ttl + +Set a maximum TTL value for entries in the cache. + #### reverse_mapping Stores a reverse mapping of IP addresses after responding to a DNS query in order to provide domain names when routing. diff --git a/option/dns.go b/option/dns.go index c770a919f5..a5062eec28 100644 --- a/option/dns.go +++ b/option/dns.go @@ -110,6 +110,8 @@ type DNSClientOptions struct { DisableExpire bool `json:"disable_expire,omitempty"` IndependentCache bool `json:"independent_cache,omitempty"` CacheCapacity uint32 `json:"cache_capacity,omitempty"` + MinCacheTTL uint32 `json:"min_cache_ttl,omitempty"` + MaxCacheTTL uint32 `json:"max_cache_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` } From 88a9e58d6023ec072bb5c077b691c59ba86022e1 Mon Sep 17 00:00:00 2001 From: lux5am Date: Tue, 4 Feb 2025 00:40:41 +0800 Subject: [PATCH 52/57] add `round_robin_cache` in dns config Signed-off-by: lux5am --- dns/client.go | 108 +++++++++++++++++++++++++++----- dns/router.go | 1 + docs/configuration/dns/index.md | 5 ++ option/dns.go | 1 + 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/dns/client.go b/dns/client.go index 2a767eece4..bf3ceff299 100644 --- a/dns/client.go +++ b/dns/client.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "strings" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -31,20 +32,88 @@ var ( var _ adapter.DNSClient = (*Client)(nil) +func rotateSlice[T any](slice []T, steps int32) []T { + if len(slice) <= 1 { + return slice + } + steps = steps % int32(len(slice)) + return append(slice[steps:], slice[:steps]...) +} + +func reverseRotateSlice[T any](slice []T, steps int32) []T { + if len(slice) <= 1 { + return slice + } + steps = steps % int32(len(slice)) + return append(slice[len(slice)-int(steps):], slice[:len(slice)-int(steps)]...) +} + +func removeAnswersOfType(answers []dns.RR, rrType uint16) []dns.RR { + var filteredAnswers []dns.RR + for _, ans := range answers { + if ans.Header().Rrtype != rrType { + filteredAnswers = append(filteredAnswers, ans) + } + } + return filteredAnswers +} + +type dnsMsg struct { + ipv4Index int32 + ipv6Index int32 + msg *dns.Msg +} + +func (dm *dnsMsg) RoundRobin() *dns.Msg { + rotatedMsg := dm.msg.Copy() + var ( + ipv4Answers []*dns.A + ipv6Answers []*dns.AAAA + ) + for _, ans := range rotatedMsg.Answer { + switch a := ans.(type) { + case *dns.A: + ipv4Answers = append(ipv4Answers, a) + case *dns.AAAA: + ipv6Answers = append(ipv6Answers, a) + } + } + if len(ipv4Answers) > 1 { + newIndex := (atomic.AddInt32(&dm.ipv4Index, 1) % int32(len(ipv4Answers))) + atomic.StoreInt32(&dm.ipv4Index, newIndex) + rotatedIPv4 := reverseRotateSlice(ipv4Answers, newIndex) + rotatedMsg.Answer = removeAnswersOfType(rotatedMsg.Answer, dns.TypeA) + for _, ipv4 := range rotatedIPv4 { + rotatedMsg.Answer = append(rotatedMsg.Answer, ipv4) + } + } + if len(ipv6Answers) > 1 { + newIndex := (atomic.AddInt32(&dm.ipv6Index, 1) % int32(len(ipv6Answers))) + atomic.StoreInt32(&dm.ipv6Index, newIndex) + rotatedIPv6 := reverseRotateSlice(ipv6Answers, newIndex) + rotatedMsg.Answer = removeAnswersOfType(rotatedMsg.Answer, dns.TypeAAAA) + for _, ipv6 := range rotatedIPv6 { + rotatedMsg.Answer = append(rotatedMsg.Answer, ipv6) + } + } + return rotatedMsg +} + type Client struct { timeout time.Duration disableCache bool disableExpire bool independentCache bool + roundRobinCache bool minCacheTTL uint32 maxCacheTTL uint32 clientSubnet netip.Prefix rdrc adapter.RDRCStore initRDRCFunc func() adapter.RDRCStore logger logger.ContextLogger - cache freelru.Cache[dns.Question, *dns.Msg] + cache freelru.Cache[dns.Question, *dnsMsg] cacheLock compatible.Map[dns.Question, chan struct{}] - transportCache freelru.Cache[transportCacheKey, *dns.Msg] + transportCache freelru.Cache[transportCacheKey, *dnsMsg] transportCacheLock compatible.Map[dns.Question, chan struct{}] } @@ -53,6 +122,7 @@ type ClientOptions struct { DisableCache bool DisableExpire bool IndependentCache bool + RoundRobinCache bool CacheCapacity uint32 ClientSubnet netip.Prefix MinCacheTTL uint32 @@ -67,6 +137,7 @@ func NewClient(options ClientOptions) *Client { disableCache: options.DisableCache, disableExpire: options.DisableExpire, independentCache: options.IndependentCache, + roundRobinCache: options.RoundRobinCache, clientSubnet: options.ClientSubnet, minCacheTTL: options.MinCacheTTL, maxCacheTTL: options.MaxCacheTTL, @@ -88,9 +159,9 @@ func NewClient(options ClientOptions) *Client { } if !client.disableCache { if !client.independentCache { - client.cache = common.Must1(freelru.NewSharded[dns.Question, *dns.Msg](cacheCapacity, maphash.NewHasher[dns.Question]().Hash32)) + client.cache = common.Must1(freelru.NewSharded[dns.Question, *dnsMsg](cacheCapacity, maphash.NewHasher[dns.Question]().Hash32)) } else { - client.transportCache = common.Must1(freelru.NewSharded[transportCacheKey, *dns.Msg](cacheCapacity, maphash.NewHasher[transportCacheKey]().Hash32)) + client.transportCache = common.Must1(freelru.NewSharded[transportCacheKey, *dnsMsg](cacheCapacity, maphash.NewHasher[transportCacheKey]().Hash32)) } } return client @@ -395,21 +466,21 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio } if c.disableExpire { if !c.independentCache { - c.cache.Add(question, message) + c.cache.Add(question, &dnsMsg{msg: message}) } else { c.transportCache.Add(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, message) + }, &dnsMsg{msg: message}) } } else { if !c.independentCache { - c.cache.AddWithLifetime(question, message, time.Second*time.Duration(timeToLive)) + c.cache.AddWithLifetime(question, &dnsMsg{msg: message}, time.Second*time.Duration(timeToLive)) } else { c.transportCache.AddWithLifetime(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, message, time.Second*time.Duration(timeToLive)) + }, &dnsMsg{msg: message}, time.Second*time.Duration(timeToLive)) } } } @@ -454,16 +525,25 @@ func (c *Client) questionCache(question dns.Question, transport adapter.DNSTrans return MessageToAddresses(response), nil } +func (c *Client) getRoundRobin(response *dnsMsg) *dns.Msg { + if c.roundRobinCache { + return response.RoundRobin() + } else { + return response.msg.Copy() + } +} + func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int) { var ( + resp *dnsMsg response *dns.Msg loaded bool ) if c.disableExpire { if !c.independentCache { - response, loaded = c.cache.Get(question) + resp, loaded = c.cache.Get(question) } else { - response, loaded = c.transportCache.Get(transportCacheKey{ + resp, loaded = c.transportCache.Get(transportCacheKey{ Question: question, transportTag: transport.Tag(), }) @@ -471,13 +551,13 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp if !loaded { return nil, 0 } - return response.Copy(), 0 + return c.getRoundRobin(resp), 0 } else { var expireAt time.Time if !c.independentCache { - response, expireAt, loaded = c.cache.GetWithLifetime(question) + resp, expireAt, loaded = c.cache.GetWithLifetime(question) } else { - response, expireAt, loaded = c.transportCache.GetWithLifetime(transportCacheKey{ + resp, expireAt, loaded = c.transportCache.GetWithLifetime(transportCacheKey{ Question: question, transportTag: transport.Tag(), }) @@ -497,6 +577,7 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp } return nil, 0 } + response = c.getRoundRobin(resp) var originTTL int for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { @@ -509,7 +590,6 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp if nowTTL < 0 { nowTTL = 0 } - response = response.Copy() if originTTL > 0 { duration := uint32(originTTL - nowTTL) for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { diff --git a/dns/router.go b/dns/router.go index 9ad24f42bc..a5427d69a1 100644 --- a/dns/router.go +++ b/dns/router.go @@ -55,6 +55,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp DisableCache: options.DNSClientOptions.DisableCache, DisableExpire: options.DNSClientOptions.DisableExpire, IndependentCache: options.DNSClientOptions.IndependentCache, + RoundRobinCache: options.DNSClientOptions.RoundRobinCache, CacheCapacity: options.DNSClientOptions.CacheCapacity, ClientSubnet: options.DNSClientOptions.ClientSubnet.Build(netip.Prefix{}), MinCacheTTL: options.DNSClientOptions.MinCacheTTL, diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md index 6068b2a7fd..3d55d87d8b 100644 --- a/docs/configuration/dns/index.md +++ b/docs/configuration/dns/index.md @@ -24,6 +24,7 @@ icon: material/alert-decagram "disable_cache": false, "disable_expire": false, "independent_cache": false, + "round_robin_cache": false, "cache_capacity": 0, "min_cache_ttl": 0, "max_cache_ttl": 0, @@ -67,6 +68,10 @@ Disable dns cache expire. Make each DNS server's cache independent for special purposes. If enabled, will slightly degrade performance. +#### round_robin_cache + +Make the order of cached response addresses rotated in round robin manner. + #### cache_capacity !!! question "Since sing-box 1.11.0" diff --git a/option/dns.go b/option/dns.go index a5062eec28..b9354bb6b5 100644 --- a/option/dns.go +++ b/option/dns.go @@ -109,6 +109,7 @@ type DNSClientOptions struct { DisableCache bool `json:"disable_cache,omitempty"` DisableExpire bool `json:"disable_expire,omitempty"` IndependentCache bool `json:"independent_cache,omitempty"` + RoundRobinCache bool `json:"round_robin_cache,omitempty"` CacheCapacity uint32 `json:"cache_capacity,omitempty"` MinCacheTTL uint32 `json:"min_cache_ttl,omitempty"` MaxCacheTTL uint32 `json:"max_cache_ttl,omitempty"` From d1a65c727ad8cd74fa6867e4004e770d89a67551 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Wed, 24 Dec 2025 22:17:07 +0800 Subject: [PATCH 53/57] add `lazy_cache_ttl` in dns config --- adapter/dns.go | 6 +- dns/client.go | 218 ++++++++++++++++++++++++++++++++++++------------- dns/router.go | 19 ++++- option/dns.go | 1 + 4 files changed, 179 insertions(+), 65 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index cfcfffbc66..d295dd326b 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -26,9 +26,11 @@ type DNSRouter interface { type DNSClient interface { Start() - Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) - Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) + Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error, bool) + Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error, bool) ClearCache() + UpdateDnsCacheFromContext(ctx context.Context) bool + UpdateDnsCacheToContext(ctx context.Context) context.Context } type DNSQueryOptions struct { diff --git a/dns/client.go b/dns/client.go index bf3ceff299..47f5749912 100644 --- a/dns/client.go +++ b/dns/client.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "strings" + "sync" "sync/atomic" "time" @@ -59,9 +60,10 @@ func removeAnswersOfType(answers []dns.RR, rrType uint16) []dns.RR { } type dnsMsg struct { - ipv4Index int32 - ipv6Index int32 - msg *dns.Msg + ipv4Index int32 + ipv6Index int32 + msg *dns.Msg + expireTime time.Time } func (dm *dnsMsg) RoundRobin() *dns.Msg { @@ -100,21 +102,26 @@ func (dm *dnsMsg) RoundRobin() *dns.Msg { } type Client struct { - timeout time.Duration - disableCache bool - disableExpire bool - independentCache bool - roundRobinCache bool - minCacheTTL uint32 - maxCacheTTL uint32 - clientSubnet netip.Prefix - rdrc adapter.RDRCStore - initRDRCFunc func() adapter.RDRCStore - logger logger.ContextLogger - cache freelru.Cache[dns.Question, *dnsMsg] - cacheLock compatible.Map[dns.Question, chan struct{}] - transportCache freelru.Cache[transportCacheKey, *dnsMsg] - transportCacheLock compatible.Map[dns.Question, chan struct{}] + timeout time.Duration + disableCache bool + disableExpire bool + independentCache bool + roundRobinCache bool + useLazyCache bool + lazyCacheTTL uint32 + minCacheTTL uint32 + maxCacheTTL uint32 + clientSubnet netip.Prefix + rdrc adapter.RDRCStore + initRDRCFunc func() adapter.RDRCStore + logger logger.ContextLogger + cache freelru.Cache[dns.Question, *dnsMsg] + cacheLock compatible.Map[dns.Question, chan struct{}] + transportCache freelru.Cache[transportCacheKey, *dnsMsg] + transportCacheLock compatible.Map[dns.Question, chan struct{}] + cacheUpdating map[dns.Question]struct{} + transportCacheUpdating map[transportCacheKey]struct{} + updateAccess sync.Mutex } type ClientOptions struct { @@ -123,6 +130,7 @@ type ClientOptions struct { DisableExpire bool IndependentCache bool RoundRobinCache bool + LazyCacheTTL uint32 CacheCapacity uint32 ClientSubnet netip.Prefix MinCacheTTL uint32 @@ -138,6 +146,8 @@ func NewClient(options ClientOptions) *Client { disableExpire: options.DisableExpire, independentCache: options.IndependentCache, roundRobinCache: options.RoundRobinCache, + useLazyCache: options.LazyCacheTTL > 0, + lazyCacheTTL: options.LazyCacheTTL, clientSubnet: options.ClientSubnet, minCacheTTL: options.MinCacheTTL, maxCacheTTL: options.MaxCacheTTL, @@ -160,8 +170,10 @@ func NewClient(options ClientOptions) *Client { if !client.disableCache { if !client.independentCache { client.cache = common.Must1(freelru.NewSharded[dns.Question, *dnsMsg](cacheCapacity, maphash.NewHasher[dns.Question]().Hash32)) + client.cacheUpdating = make(map[dns.Question]struct{}) } else { client.transportCache = common.Must1(freelru.NewSharded[transportCacheKey, *dnsMsg](cacheCapacity, maphash.NewHasher[transportCacheKey]().Hash32)) + client.transportCacheUpdating = make(map[transportCacheKey]struct{}) } } return client @@ -192,19 +204,71 @@ func extractNegativeTTL(response *dns.Msg) (uint32, bool) { return 0, false } -func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) { +type updateDnsCacheContext struct{} + +func (c *Client) UpdateDnsCacheFromContext(ctx context.Context) bool { + _, ok := ctx.Value((*updateDnsCacheContext)(nil)).(struct{}) + return ok +} + +func (c *Client) UpdateDnsCacheToContext(ctx context.Context) context.Context { + return context.WithValue(ctx, (*updateDnsCacheContext)(nil), struct{}{}) +} + +func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error, bool) { if len(message.Question) == 0 { if c.logger != nil { c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) } - return FixedResponseStatus(message, dns.RcodeFormatError), nil + return FixedResponseStatus(message, dns.RcodeFormatError), nil, false } question := message.Question[0] if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only || question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only { if c.logger != nil { c.logger.DebugContext(ctx, "strategy rejected") } - return FixedResponseStatus(message, dns.RcodeSuccess), nil + return FixedResponseStatus(message, dns.RcodeSuccess), nil, false + } + isUpdatingCache := c.UpdateDnsCacheFromContext(ctx) + if isUpdatingCache { + var key interface{} + isUpdating := func() bool { + c.updateAccess.Lock() + defer c.updateAccess.Unlock() + var exist bool + if !c.independentCache { + _, exist = c.cacheUpdating[question] + if !exist { + c.cacheUpdating[question] = struct{}{} + key = question + } + } else { + withTransportKey := transportCacheKey{ + Question: question, + transportTag: transport.Tag(), + } + _, exist = c.transportCacheUpdating[withTransportKey] + if !exist { + c.transportCacheUpdating[withTransportKey] = struct{}{} + key = withTransportKey + } + } + return exist + }() + if !isUpdating && key != nil { + defer func() { + c.updateAccess.Lock() + defer c.updateAccess.Unlock() + if !c.independentCache { + delete(c.cacheUpdating, key.(dns.Question)) + } else { + delete(c.transportCacheUpdating, key.(transportCacheKey)) + } + }() + } + if isUpdating { + return nil, nil, false + } } clientSubnet := options.ClientSubnet if !clientSubnet.IsValid() { @@ -223,14 +287,14 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m len(message.Extra[0].(*dns.OPT).Option) == 0) && !options.ClientSubnet.IsValid() disableCache := !isSimpleRequest || c.disableCache || options.DisableCache - if !disableCache { + if !disableCache && !isUpdatingCache { if c.cache != nil { cond, loaded := c.cacheLock.LoadOrStore(question, make(chan struct{})) if loaded { select { case <-cond: case <-ctx.Done(): - return nil, ctx.Err() + return nil, ctx.Err(), false } } else { defer func() { @@ -244,7 +308,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m select { case <-cond: case <-ctx.Done(): - return nil, ctx.Err() + return nil, ctx.Err(), false } } else { defer func() { @@ -253,24 +317,24 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m }() } } - response, ttl := c.loadResponse(question, transport) + response, ttl, stale := c.loadResponse(question, transport) if response != nil { logCachedResponse(c.logger, ctx, response, ttl) response.Id = message.Id - return response, nil + return response, nil, stale } } messageId := message.Id contextTransport, clientSubnetLoaded := transportTagFromContext(ctx) if clientSubnetLoaded && transport.Tag() == contextTransport { - return nil, E.New("DNS query loopback in transport[", contextTransport, "]") + return nil, E.New("DNS query loopback in transport[", contextTransport, "]"), false } ctx = contextWithTransportTag(ctx, transport.Tag()) if !disableCache && responseChecker != nil && c.rdrc != nil { rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype) if rejected { - return nil, ErrResponseRejectedCached + return nil, ErrResponseRejectedCached, false } } ctx, cancel := context.WithTimeout(ctx, c.timeout) @@ -281,7 +345,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m if errors.As(err, &rcodeError) { response = FixedResponseStatus(message, int(rcodeError)) } else { - return nil, err + return nil, err, false } } /*if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA { @@ -335,7 +399,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger) } logRejectedResponse(c.logger, ctx, response) - return response, ErrResponseRejected + return response, ErrResponseRejected, false } } if question.Qtype == dns.TypeHTTPS { @@ -401,10 +465,10 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } } logExchangedResponse(c.logger, ctx, response, timeToLive) - return response, nil + return response, nil, false } -func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) { +func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error, bool) { domain = FqdnToDomain(domain) dnsName := dns.Fqdn(domain) var strategy C.DomainStrategy @@ -420,28 +484,31 @@ func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, dom } var response4 []netip.Addr var response6 []netip.Addr + var stale4, stale6 bool var group task.Group group.Append("exchange4", func(ctx context.Context) error { - response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker) + response, err, stale := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker) if err != nil { return err } response4 = response + stale4 = stale return nil }) group.Append("exchange6", func(ctx context.Context) error { - response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker) + response, err, stale := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker) if err != nil { return err } response6 = response + stale6 = stale return nil }) err := group.Run(ctx) if len(response4) == 0 && len(response6) == 0 { - return nil, err + return nil, err, false } - return sortAddresses(response4, response6, strategy), nil + return sortAddresses(response4, response6, strategy), nil, stale4 || stale6 } func (c *Client) ClearCache() { @@ -464,38 +531,45 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio if timeToLive == 0 { return } + pdnsMsg := &dnsMsg{msg: message} if c.disableExpire { if !c.independentCache { - c.cache.Add(question, &dnsMsg{msg: message}) + c.cache.Add(question, pdnsMsg) } else { c.transportCache.Add(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, &dnsMsg{msg: message}) + }, pdnsMsg) } } else { + lifetime := time.Second * time.Duration(timeToLive) + pdnsMsg.expireTime = time.Now().Add(lifetime) + if c.useLazyCache { + lifetime = lifetime + time.Second*time.Duration(c.lazyCacheTTL) + } if !c.independentCache { - c.cache.AddWithLifetime(question, &dnsMsg{msg: message}, time.Second*time.Duration(timeToLive)) + c.cache.AddWithLifetime(question, pdnsMsg, lifetime) } else { c.transportCache.AddWithLifetime(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, &dnsMsg{msg: message}, time.Second*time.Duration(timeToLive)) + }, pdnsMsg, lifetime) } } } -func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) { +func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error, bool) { question := dns.Question{ Name: name, Qtype: qType, Qclass: dns.ClassINET, } + isUpdatingCache := c.UpdateDnsCacheFromContext(ctx) disableCache := c.disableCache || options.DisableCache - if !disableCache { - cachedAddresses, err := c.questionCache(question, transport) + if !disableCache && !isUpdatingCache { + cachedAddresses, err, stale := c.questionCache(question, transport) if err != ErrNotCached { - return cachedAddresses, err + return cachedAddresses, err, stale } } message := dns.Msg{ @@ -504,25 +578,28 @@ func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTran }, Question: []dns.Question{question}, } - response, err := c.Exchange(ctx, transport, &message, options, responseChecker) + response, err, _ := c.Exchange(ctx, transport, &message, options, responseChecker) if err != nil { - return nil, err + return nil, err, false + } + if response == nil { + return nil, nil, false } if response.Rcode != dns.RcodeSuccess { - return nil, RcodeError(response.Rcode) + return nil, RcodeError(response.Rcode), false } - return MessageToAddresses(response), nil + return MessageToAddresses(response), nil, false } -func (c *Client) questionCache(question dns.Question, transport adapter.DNSTransport) ([]netip.Addr, error) { - response, _ := c.loadResponse(question, transport) +func (c *Client) questionCache(question dns.Question, transport adapter.DNSTransport) ([]netip.Addr, error, bool) { + response, _, stale := c.loadResponse(question, transport) if response == nil { - return nil, ErrNotCached + return nil, ErrNotCached, false } if response.Rcode != dns.RcodeSuccess { - return nil, RcodeError(response.Rcode) + return nil, RcodeError(response.Rcode), false } - return MessageToAddresses(response), nil + return MessageToAddresses(response), nil, stale } func (c *Client) getRoundRobin(response *dnsMsg) *dns.Msg { @@ -533,7 +610,7 @@ func (c *Client) getRoundRobin(response *dnsMsg) *dns.Msg { } } -func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int) { +func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { var ( resp *dnsMsg response *dns.Msg @@ -549,9 +626,9 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp }) } if !loaded { - return nil, 0 + return nil, 0, false } - return c.getRoundRobin(resp), 0 + return c.getRoundRobin(resp), 0, false } else { var expireAt time.Time if !c.independentCache { @@ -563,7 +640,7 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp }) } if !loaded { - return nil, 0 + return nil, 0, false } timeNow := time.Now() if timeNow.After(expireAt) { @@ -575,8 +652,9 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp transportTag: transport.Tag(), }) } - return nil, 0 + return nil, 0, false } + stale := c.useLazyCache && !resp.expireTime.IsZero() && timeNow.After(resp.expireTime) response = c.getRoundRobin(resp) var originTTL int for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { @@ -590,6 +668,28 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp if nowTTL < 0 { nowTTL = 0 } + if stale { + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + record.Header().Ttl = 5 + } + } + opt := response.IsEdns0() + if opt == nil { + opt = &dns.OPT{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeOPT, + }, + } + opt.SetUDPSize(4096) + response.Extra = append(response.Extra, opt) + } + opt.Option = append(opt.Option, &dns.EDNS0_EDE{ + InfoCode: dns.ExtendedErrorCodeStaleAnswer, + }) + return response, 0, true + } if originTTL > 0 { duration := uint32(originTTL - nowTTL) for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { @@ -604,7 +704,7 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp } } } - return response, nowTTL + return response, nowTTL, false } } diff --git a/dns/router.go b/dns/router.go index a5427d69a1..5f0daaac1d 100644 --- a/dns/router.go +++ b/dns/router.go @@ -60,6 +60,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp ClientSubnet: options.DNSClientOptions.ClientSubnet.Build(netip.Prefix{}), MinCacheTTL: options.DNSClientOptions.MinCacheTTL, MaxCacheTTL: options.DNSClientOptions.MaxCacheTTL, + LazyCacheTTL: options.DNSClientOptions.LazyCacheTTL, RDRC: func() adapter.RDRCStore { cacheFile := service.FromContext[adapter.CacheFile](ctx) if cacheFile == nil { @@ -230,6 +231,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte response *mDNS.Msg transport adapter.DNSTransport err error + stale bool ) var metadata *adapter.InboundContext ctx, metadata = adapter.ExtendContext(ctx) @@ -255,7 +257,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } - response, err = r.client.Exchange(ctx, transport, message, options, nil) + response, err, stale = r.client.Exchange(ctx, transport, message, options, nil) } else { var ( rule adapter.DNSRule @@ -300,7 +302,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte if dnsOptions.Strategy == C.DomainStrategyAsIS { dnsOptions.Strategy = r.defaultDomainStrategy } - response, err = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) + response, err, stale = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) var rejected bool if err != nil { if errors.Is(err, ErrResponseRejectedCached) { @@ -324,6 +326,10 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte if err != nil { return nil, err } + if stale { + r.logger.DebugContext(ctx, "updating stale cache ", FormatQuestion(message.Question[0].String())) + go r.Exchange(r.client.UpdateDnsCacheToContext(context.WithoutCancel(ctx)), message, options) + } if r.dnsReverseMapping != nil && len(message.Question) > 0 && response != nil && len(response.Answer) > 0 { if transport == nil || transport.Type() != C.DNSTypeFakeIP { for _, answer := range response.Answer { @@ -343,6 +349,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ var ( responseAddrs []netip.Addr err error + stale bool ) printResult := func() { if err == nil && len(responseAddrs) == 0 { @@ -378,7 +385,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } - responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) + responseAddrs, err, stale = r.client.Lookup(ctx, transport, domain, options, nil) } else { var ( transport adapter.DNSTransport @@ -416,13 +423,17 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ if dnsOptions.Strategy == C.DomainStrategyAsIS { dnsOptions.Strategy = r.defaultDomainStrategy } - responseAddrs, err = r.client.Lookup(dnsCtx, transport, domain, dnsOptions, responseCheck) + responseAddrs, err, stale = r.client.Lookup(dnsCtx, transport, domain, dnsOptions, responseCheck) if responseCheck == nil || err == nil { break } printResult() } } + if stale { + r.logger.DebugContext(ctx, "updating stale cache for lookup ", domain) + go r.Lookup(r.client.UpdateDnsCacheToContext(context.WithoutCancel(ctx)), domain, options) + } response: printResult() if len(responseAddrs) > 0 { diff --git a/option/dns.go b/option/dns.go index b9354bb6b5..902c804817 100644 --- a/option/dns.go +++ b/option/dns.go @@ -113,6 +113,7 @@ type DNSClientOptions struct { CacheCapacity uint32 `json:"cache_capacity,omitempty"` MinCacheTTL uint32 `json:"min_cache_ttl,omitempty"` MaxCacheTTL uint32 `json:"max_cache_ttl,omitempty"` + LazyCacheTTL uint32 `json:"lazy_cache_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` } From c012564813e5d97ca711a9e0e0eb5a19a1890240 Mon Sep 17 00:00:00 2001 From: reF1nd Date: Tue, 2 Dec 2025 12:24:48 +0800 Subject: [PATCH 54/57] DNS: support `lazy_cache_ttl` in DNS rules --- adapter/dns.go | 1 + dns/client.go | 10 +++++++--- dns/router.go | 6 ++++++ docs/configuration/dns/rule_action.md | 12 ++++++++++-- docs/configuration/dns/rule_action.zh.md | 12 ++++++++++-- option/rule_action.go | 2 ++ route/rule/rule_action.go | 3 +++ 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index d295dd326b..88d5572bee 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -40,6 +40,7 @@ type DNSQueryOptions struct { DisableCache bool RewriteTTL *uint32 ClientSubnet netip.Prefix + LazyCacheTTL *uint32 } func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (*DNSQueryOptions, error) { diff --git a/dns/client.go b/dns/client.go index 47f5749912..e10fd7f177 100644 --- a/dns/client.go +++ b/dns/client.go @@ -451,7 +451,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } } if !disableCache { - c.storeCache(transport, question, response, timeToLive) + c.storeCache(transport, question, response, timeToLive, options.LazyCacheTTL) } response.Id = messageId requestEDNSOpt := message.IsEdns0() @@ -527,7 +527,7 @@ func sortAddresses(response4 []netip.Addr, response6 []netip.Addr, strategy C.Do } } -func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32) { +func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32, lazyCacheTTL *uint32) { if timeToLive == 0 { return } @@ -544,7 +544,11 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio } else { lifetime := time.Second * time.Duration(timeToLive) pdnsMsg.expireTime = time.Now().Add(lifetime) - if c.useLazyCache { + if lazyCacheTTL != nil { + if *lazyCacheTTL > 0 { + lifetime = lifetime + (time.Second * time.Duration(*lazyCacheTTL)) + } + } else if c.useLazyCache { lifetime = lifetime + time.Second*time.Duration(c.lazyCacheTTL) } if !c.independentCache { diff --git a/dns/router.go b/dns/router.go index 5f0daaac1d..fb246cbc5c 100644 --- a/dns/router.go +++ b/dns/router.go @@ -172,6 +172,9 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, if action.ClientSubnet.IsValid() { options.ClientSubnet = action.ClientSubnet } + if action.LazyCacheTTL != nil { + options.LazyCacheTTL = action.LazyCacheTTL + } if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { if options.Strategy == C.DomainStrategyAsIS { options.Strategy = legacyTransport.LegacyStrategy() @@ -194,6 +197,9 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, if action.ClientSubnet.IsValid() { options.ClientSubnet = action.ClientSubnet } + if action.LazyCacheTTL != nil { + options.LazyCacheTTL = action.LazyCacheTTL + } case *R.RuleActionReject: return nil, currentRule, currentRuleIndex case *R.RuleActionPredefined: diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index db9033f8b7..412fd9f059 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -18,7 +18,8 @@ icon: material/new-box "strategy": "", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` @@ -54,6 +55,12 @@ If value is an IP address instead of prefix, `/32` or `/128` will be appended au Will overrides `dns.client_subnet`. +#### lazy_cache_ttl + +Serve expired cached response with given extra ttl for this rule. It will attempt to refresh the query in the background. + +Takes priority over the global `dns.lazy_cache_ttl` setting. + ### route-options ```json @@ -61,7 +68,8 @@ Will overrides `dns.client_subnet`. "action": "route-options", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 9e59c6bd2b..170bf04d38 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -18,7 +18,8 @@ icon: material/new-box "strategy": "", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` @@ -54,6 +55,12 @@ icon: material/new-box 将覆盖 `dns.client_subnet`. +#### lazy_cache_ttl + +为此规则提供已过期的缓存响应,并使用给定的额外 TTL。它将尝试在后台刷新查询。 + +优先级高于全局的 `dns.lazy_cache_ttl` 设置。 + ### route-options ```json @@ -61,7 +68,8 @@ icon: material/new-box "action": "route-options", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` diff --git a/option/rule_action.go b/option/rule_action.go index 5265a3b979..87a54806d1 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -196,6 +196,7 @@ type DNSRouteActionOptions struct { DisableCache bool `json:"disable_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + LazyCacheTTL *uint32 `json:"lazy_cache_ttl,omitempty"` } type _DNSRouteOptionsActionOptions struct { @@ -203,6 +204,7 @@ type _DNSRouteOptionsActionOptions struct { DisableCache bool `json:"disable_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + LazyCacheTTL *uint32 `json:"lazy_cache_ttl,omitempty"` } type DNSRouteOptionsActionOptions _DNSRouteOptionsActionOptions diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 8b7df0352a..e96ab071ea 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -133,6 +133,7 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) DisableCache: action.RouteOptions.DisableCache, RewriteTTL: action.RouteOptions.RewriteTTL, ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + LazyCacheTTL: action.RouteOptions.LazyCacheTTL, }, } case C.RuleActionTypeRouteOptions: @@ -141,6 +142,7 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) DisableCache: action.RouteOptionsOptions.DisableCache, RewriteTTL: action.RouteOptionsOptions.RewriteTTL, ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptionsOptions.ClientSubnet)), + LazyCacheTTL: action.RouteOptionsOptions.LazyCacheTTL, } case C.RuleActionTypeReject: return &RuleActionReject{ @@ -289,6 +291,7 @@ type RuleActionDNSRouteOptions struct { DisableCache bool RewriteTTL *uint32 ClientSubnet netip.Prefix + LazyCacheTTL *uint32 } func (r *RuleActionDNSRouteOptions) Type() string { From 6ebb8a7af59ee2e6b488709ad91c68dfef5dfb74 Mon Sep 17 00:00:00 2001 From: xireiki Date: Wed, 8 Oct 2025 19:07:40 +0800 Subject: [PATCH 55/57] Support temporary disable rule --- adapter/dns.go | 1 + adapter/router.go | 3 +- adapter/rule.go | 3 ++ dns/router.go | 11 +++++++ experimental/clashapi/ctxkeys.go | 2 ++ experimental/clashapi/rules.go | 50 ++++++++++++++++++++++++++++++++ route/route.go | 8 +++++ route/router.go | 4 +++ route/rule/rule_abstract.go | 19 ++++++++++++ route/rule/rule_default.go | 14 +++++++-- route/rule/rule_dns.go | 10 +++++++ 11 files changed, 122 insertions(+), 3 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index 88d5572bee..32a905bb46 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -21,6 +21,7 @@ type DNSRouter interface { ClearCache() LookupReverseMapping(ip netip.Addr) (string, bool) Rules() []DNSRule + Rule(uuid string) (DNSRule, bool) ResetNetwork() } diff --git a/adapter/router.go b/adapter/router.go index 5921b1f730..1c3e52db65 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -9,7 +9,7 @@ import ( "time" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" @@ -27,6 +27,7 @@ type Router interface { RuleSet(tag string) (RuleSet, bool) Rules() []Rule NeedFindProcess() bool + Rule(uuid string) (Rule, bool) NeedFindNeighbor() bool NeighborResolver() NeighborResolver AppendTracker(tracker ConnectionTracker) diff --git a/adapter/rule.go b/adapter/rule.go index 7c8aceb59f..963e91eed8 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -13,6 +13,9 @@ type HeadlessRule interface { type Rule interface { HeadlessRule SimpleLifecycle + Disabled() bool + UUID() string + ChangeStatus() Type() string Action() RuleAction } diff --git a/dns/router.go b/dns/router.go index fb246cbc5c..19230a3dd7 100644 --- a/dns/router.go +++ b/dns/router.go @@ -35,6 +35,7 @@ type Router struct { outbound adapter.OutboundManager client adapter.DNSClient rules []adapter.DNSRule + ruleByUUID map[string]adapter.DNSRule defaultDomainStrategy C.DomainStrategy dnsReverseMapping freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface @@ -48,6 +49,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp transport: service.FromContext[adapter.DNSTransportManager](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), rules: make([]adapter.DNSRule, 0, len(options.Rules)), + ruleByUUID: make(map[string]adapter.DNSRule), defaultDomainStrategy: C.DomainStrategy(options.Strategy), defaultRejectRcode: options.DefaultRejectRcode.Build(), } @@ -86,6 +88,7 @@ func (r *Router) Initialize(rules []option.DNSRule) error { return E.Cause(err, "parse dns rule[", i, "]") } r.rules = append(r.rules, dnsRule) + r.ruleByUUID[dnsRule.UUID()] = dnsRule } return nil } @@ -134,6 +137,9 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } for ; currentRuleIndex < len(r.rules); currentRuleIndex++ { currentRule := r.rules[currentRuleIndex] + if currentRule.Disabled() { + continue + } if currentRule.WithAddressLimit() && !isAddressQuery { continue } @@ -473,6 +479,11 @@ func (r *Router) Rules() []adapter.DNSRule { return r.rules } +func (r *Router) Rule(uuid string) (adapter.DNSRule, bool) { + rule, exists := r.ruleByUUID[uuid] + return rule, exists +} + func (r *Router) ClearCache() { r.client.ClearCache() if r.platformInterface != nil { diff --git a/experimental/clashapi/ctxkeys.go b/experimental/clashapi/ctxkeys.go index 3a88802627..aa13ce1ff9 100644 --- a/experimental/clashapi/ctxkeys.go +++ b/experimental/clashapi/ctxkeys.go @@ -5,6 +5,8 @@ var ( CtxKeyProviderName = contextKey("provider name") CtxKeyProxy = contextKey("proxy") CtxKeyProvider = contextKey("provider") + CtxKeyRule = contextKey("rule") + CtxKeyRuleUUID = contextKey("rule uuid") ) type contextKey string diff --git a/experimental/clashapi/rules.go b/experimental/clashapi/rules.go index b99b8ab99c..383d583d1a 100644 --- a/experimental/clashapi/rules.go +++ b/experimental/clashapi/rules.go @@ -1,6 +1,7 @@ package clashapi import ( + "context" "net/http" "github.com/sagernet/sing-box/adapter" @@ -12,6 +13,10 @@ import ( func ruleRouter(router adapter.Router, dnsRouter adapter.DNSRouter) http.Handler { r := chi.NewRouter() r.Get("/", getRules(router, dnsRouter)) + r.Route("/{uuid}", func(r chi.Router) { + r.Use(parseRuleUUID, findRuleByUUID(router, dnsRouter)) + r.Put("/", changeRuleStatus) + }) return r } @@ -19,6 +24,9 @@ type Rule struct { Type string `json:"type"` Payload string `json:"payload"` Proxy string `json:"proxy"` + + Disabled bool `json:"disabled,omitempty"` + UUID string `json:"uuid,omitempty"` } func getRules(router adapter.Router, dnsRouter adapter.DNSRouter) func(w http.ResponseWriter, r *http.Request) { @@ -29,6 +37,9 @@ func getRules(router adapter.Router, dnsRouter adapter.DNSRouter) func(w http.Re Type: rule.Type(), Payload: rule.String(), Proxy: rule.Action().String(), + + Disabled: rule.Disabled(), + UUID: rule.UUID(), }) } for _, rule := range router.Rules() { @@ -36,6 +47,9 @@ func getRules(router adapter.Router, dnsRouter adapter.DNSRouter) func(w http.Re Type: rule.Type(), Payload: rule.String(), Proxy: rule.Action().String(), + + Disabled: rule.Disabled(), + UUID: rule.UUID(), }) } render.JSON(w, r, render.M{ @@ -43,3 +57,39 @@ func getRules(router adapter.Router, dnsRouter adapter.DNSRouter) func(w http.Re }) } } + +func parseRuleUUID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uuid := getEscapeParam(r, "uuid") + ctx := context.WithValue(r.Context(), CtxKeyRuleUUID, uuid) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func findRuleByUUID(router adapter.Router, dnsRouter adapter.DNSRouter) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uuid := r.Context().Value(CtxKeyRuleUUID).(string) + routeRule, exist := router.Rule(uuid) + if exist { + ctx := context.WithValue(r.Context(), CtxKeyRule, routeRule) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + dnsRule, dnsExist := dnsRouter.Rule(uuid) + if dnsExist { + ctx := context.WithValue(r.Context(), CtxKeyRule, adapter.Rule(dnsRule)) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + }) + } +} + +func changeRuleStatus(w http.ResponseWriter, r *http.Request) { + rule := r.Context().Value(CtxKeyRule).(adapter.Rule) + rule.ChangeStatus() + render.NoContent(w, r) +} diff --git a/route/route.go b/route/route.go index ac7019ec07..f4f23fea2f 100644 --- a/route/route.go +++ b/route/route.go @@ -488,6 +488,9 @@ func (r *Router) matchRule( match: for currentRuleIndex, currentRule := range r.rules { + if currentRule.Disabled() { + continue + } metadata.ResetRuleCache() if !currentRule.Match(metadata) { continue @@ -883,3 +886,8 @@ func isAllIPv6(addresses []netip.Addr) bool { } return true } + +func (r *Router) Rule(uuid string) (adapter.Rule, bool) { + rule, exists := r.ruleByUUID[uuid] + return rule, exists +} diff --git a/route/router.go b/route/router.go index a4f09b807f..8ba3b879c1 100644 --- a/route/router.go +++ b/route/router.go @@ -30,6 +30,7 @@ type Router struct { connection adapter.ConnectionManager network adapter.NetworkManager rules []adapter.Rule + ruleByUUID map[string]adapter.Rule needFindProcess bool needFindNeighbor bool leaseFiles []string @@ -57,6 +58,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route connection: service.FromContext[adapter.ConnectionManager](ctx), network: service.FromContext[adapter.NetworkManager](ctx), rules: make([]adapter.Rule, 0, len(options.Rules)), + ruleByUUID: make(map[string]adapter.Rule), ruleSetMap: make(map[string]adapter.RuleSet), needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || options.FindNeighbor, @@ -78,7 +80,9 @@ func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) erro if err != nil { return E.Cause(err, "parse rule[", i, "]") } + uuid := rule.UUID() r.rules = append(r.rules, rule) + r.ruleByUUID[uuid] = rule } for i, options := range ruleSets { if _, exists := r.ruleSetMap[options.Tag]; exists { diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 1831943bf3..f044304e43 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -10,7 +10,25 @@ import ( F "github.com/sagernet/sing/common/format" ) +type abstractRule struct { + disabled bool + uuid string +} + +func (r *abstractRule) Disabled() bool { + return r.disabled +} + +func (r *abstractRule) UUID() string { + return r.uuid +} + +func (r *abstractRule) ChangeStatus() { + r.disabled = !r.disabled +} + type abstractDefaultRule struct { + abstractRule items []RuleItem sourceAddressItems []RuleItem sourcePortItems []RuleItem @@ -155,6 +173,7 @@ func (r *abstractDefaultRule) String() string { } type abstractLogicalRule struct { + abstractRule rules []adapter.HeadlessRule mode string domainMatchStrategy C.DomainMatchStrategy diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 81e3b3e1eb..d7af79ec5a 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -10,6 +10,8 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + + "github.com/gofrs/uuid/v5" ) func NewRule(ctx context.Context, logger log.ContextLogger, options option.Rule, checkOutbound bool) (adapter.Rule, error) { @@ -57,11 +59,15 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio if err != nil { return nil, E.Cause(err, "action") } + id, _ := uuid.NewV4() rule := &DefaultRule{ abstractDefaultRule{ domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), - invert: options.Invert, - action: action, + abstractRule: abstractRule{ + uuid: id.String(), + }, + invert: options.Invert, + action: action, }, } router := service.FromContext[adapter.Router](ctx) @@ -306,8 +312,12 @@ func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options optio if err != nil { return nil, E.Cause(err, "action") } + id, _ := uuid.NewV4() rule := &LogicalRule{ abstractLogicalRule{ + abstractRule: abstractRule{ + uuid: id.String(), + }, rules: make([]adapter.HeadlessRule, len(options.Rules)), domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), invert: options.Invert, diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index dd45d98c6c..6da8815f97 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -10,6 +10,8 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + + "github.com/gofrs/uuid/v5" ) func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DNSRule, checkServer bool) (adapter.DNSRule, error) { @@ -48,8 +50,12 @@ type DefaultDNSRule struct { } func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule) (*DefaultDNSRule, error) { + id, _ := uuid.NewV4() rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ + abstractRule: abstractRule{ + uuid: id.String(), + }, invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, @@ -326,8 +332,12 @@ type LogicalDNSRule struct { } func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) { + id, _ := uuid.NewV4() r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ + abstractRule: abstractRule{ + uuid: id.String(), + }, rules: make([]adapter.HeadlessRule, len(options.Rules)), invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), From 4ed2356fecda7726d83b8fa28c99860a46251421 Mon Sep 17 00:00:00 2001 From: xireiki Date: Wed, 22 Oct 2025 01:12:57 +0800 Subject: [PATCH 56/57] Support override TLS option --- docs/configuration/provider/index.md | 10 +++- docs/configuration/provider/index.zh.md | 10 +++- docs/configuration/provider/override_tls.md | 16 ++++++ .../configuration/provider/override_tls.zh.md | 16 ++++++ mkdocs.yml | 2 + option/provider.go | 52 +++++++++++++++++++ provider/local/lcoal.go | 4 +- provider/parser/parser.go | 47 +++++++++++++++-- provider/remote/remote.go | 4 +- 9 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 docs/configuration/provider/override_tls.md create mode 100644 docs/configuration/provider/override_tls.zh.md diff --git a/docs/configuration/provider/index.md b/docs/configuration/provider/index.md index 08d663ea88..6cdc731538 100644 --- a/docs/configuration/provider/index.md +++ b/docs/configuration/provider/index.md @@ -19,7 +19,8 @@ List of subscription providers. "interval": "", "timeout": "", }, - "override_dialer": {} + "override_dialer": {}, + "override_tls": {} } ] } @@ -45,7 +46,8 @@ List of subscription providers. "user_agent": "", "download_detour": "", "update_interval": "", - "override_dialer": {} + "override_dialer": {}, + "override_tls": {} } ] } @@ -93,6 +95,10 @@ Health check timeout. the default value is `3s`. Override dialer fields of outbounds in provider, see [Dialer Fields Override](/configuration/provider/override_dialer/) for details. +##### override_tls + +Override TLS fields of outbounds in provider, see [TLS Fields Override](/configuration/provider/override_tls/) for details. + ### Local Fields #### path diff --git a/docs/configuration/provider/index.zh.md b/docs/configuration/provider/index.zh.md index 284ad4fb2d..16ade581f7 100644 --- a/docs/configuration/provider/index.zh.md +++ b/docs/configuration/provider/index.zh.md @@ -19,7 +19,8 @@ "interval": "", "timeout": "", }, - "override_dialer": {} + "override_dialer": {}, + "override_tls": {} } ] } @@ -45,7 +46,8 @@ "user_agent": "", "download_detour": "", "update_interval": "", - "override_dialer": {} + "override_dialer": {}, + "override_tls": {} } ] } @@ -93,6 +95,10 @@ 覆写订阅内容的拨号字段, 参阅 [拨号字段覆写](/zh/configuration/provider/override_dialer/)。 +##### override_tls + +覆写订阅内容的 TLS 字段, 参阅 [TLS 字段覆写](/zh/configuration/provider/override_tls/)。 + ### 本地字段 #### path diff --git a/docs/configuration/provider/override_tls.md b/docs/configuration/provider/override_tls.md new file mode 100644 index 0000000000..e401b380e1 --- /dev/null +++ b/docs/configuration/provider/override_tls.md @@ -0,0 +1,16 @@ +### Structure + +```json +{ + "enabled": true, + "disable_sni": false, + "server_name": "example.com", + "insecure": false, + "kernel_tx": false, + "kernel_rx": false +} +``` + +### Fields + +`enabled` `disable_sni` `server_name` `insecure` `kernel_tx` `kernel_rx` see [TLS Fields](/configuration/shared/tls/#outbound). \ No newline at end of file diff --git a/docs/configuration/provider/override_tls.zh.md b/docs/configuration/provider/override_tls.zh.md new file mode 100644 index 0000000000..7c0c061986 --- /dev/null +++ b/docs/configuration/provider/override_tls.zh.md @@ -0,0 +1,16 @@ +### 结构 + +```json +{ + "enabled": true, + "disable_sni": false, + "server_name": "example.com", + "insecure": false, + "kernel_tx": false, + "kernel_rx": false +} +``` + +### 字段 + +`enabled` `disable_sni` `server_name` `insecure` `kernel_tx` `kernel_rx` 详情参阅 [TLS 字段](/zh/configuration/shared/tls/#outbound)。 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index f4f5591e94..09e088f7b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -179,6 +179,7 @@ nav: - Provider: - configuration/provider/index.md - Dialer Fields Override: configuration/provider/override_dialer.md + - TLS Fields Override: configuration/provider/override_tls.md - Service: - configuration/service/index.md - DERP: configuration/service/derp.md @@ -283,6 +284,7 @@ plugins: Outbound: 出站 Provider: 提供者 Dialer Fields Override: 拨号字段覆写 + TLS Fields Override: TLS 字段覆写 Manual: 手册 reconfigure_material: true diff --git a/option/provider.go b/option/provider.go index e77a7847a0..a5b87923e2 100644 --- a/option/provider.go +++ b/option/provider.go @@ -51,6 +51,7 @@ type ProviderLocalOptions struct { HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` OverrideDialer *OverrideDialerOptions `json:"override_dialer,omitempty"` + OverrideTLS *OverrideTLSOptions `json:"override_tls,omitempty"` } type ProviderRemoteOptions struct { @@ -65,6 +66,7 @@ type ProviderRemoteOptions struct { HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` OverrideDialer *OverrideDialerOptions `json:"override_dialer,omitempty"` + OverrideTLS *OverrideTLSOptions `json:"override_tls,omitempty"` } type ProviderInlineOptions struct { @@ -105,3 +107,53 @@ type OverrideDialerOptions struct { // Deprecated: migrated to domain resolver DomainStrategy *DomainStrategy `json:"domain_strategy,omitempty"` } + +type OverrideTLSOptions struct { + Enabled *bool `json:"enabled,omitempty"` + DisableSNI *bool `json:"disable_sni,omitempty"` + ServerName *string `json:"server_name,omitempty"` + Insecure *bool `json:"insecure,omitempty"` + ALPN *badoption.Listable[string] `json:"alpn,omitempty"` + MinVersion *string `json:"min_version,omitempty"` + MaxVersion *string `json:"max_version,omitempty"` + CipherSuites *badoption.Listable[string] `json:"cipher_suites,omitempty"` + CurvePreferences *badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` + Certificate *badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath *string `json:"certificate_path,omitempty"` + CertificatePublicKeySHA256 *badoption.Listable[[]byte] `json:"certificate_public_key_sha256,omitempty"` + ClientCertificate *badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath *string `json:"client_certificate_path,omitempty"` + ClientKey *badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath *string `json:"client_key_path,omitempty"` + CertificatePinSHA256 *string `json:"certificate_pin_sha256,omitempty"` + Fragment *bool `json:"fragment,omitempty"` + FragmentFallbackDelay *badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment *bool `json:"record_fragment,omitempty"` + KernelTx *bool `json:"kernel_tx,omitempty"` + KernelRx *bool `json:"kernel_rx,omitempty"` + ECH *OverrideECHOptions `json:"ech,omitempty"` + UTLS *OverrideUTLSOptions `json:"utls,omitempty"` + Reality *OverrideRealityOptions `json:"reality,omitempty"` +} + +type OverrideECHOptions struct { + Enabled *bool `json:"enabled,omitempty"` + Config *badoption.Listable[string] `json:"config,omitempty"` + ConfigPath *string `json:"config_path,omitempty"` + + // Deprecated: not supported by stdlib + PQSignatureSchemesEnabled *bool `json:"pq_signature_schemes_enabled,omitempty"` + // Deprecated: added by fault + DynamicRecordSizingDisabled *bool `json:"dynamic_record_sizing_disabled,omitempty"` +} + +type OverrideUTLSOptions struct { + Enabled *bool `json:"enabled,omitempty"` + Fingerprint *string `json:"fingerprint,omitempty"` +} + +type OverrideRealityOptions struct { + Enabled *bool `json:"enabled,omitempty"` + PublicKey *string `json:"public_key,omitempty"` + ShortID *string `json:"short_id,omitempty"` +} diff --git a/provider/local/lcoal.go b/provider/local/lcoal.go index 6f9f228b75..d94d78410b 100644 --- a/provider/local/lcoal.go +++ b/provider/local/lcoal.go @@ -41,6 +41,7 @@ type ProviderLocal struct { watcher *fswatch.Watcher overrideDialer *option.OverrideDialerOptions + overrideTLS *option.OverrideTLSOptions } func NewProviderInline(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderInlineOptions) (adapter.Provider, error) { @@ -73,6 +74,7 @@ func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log provider: service.FromContext[adapter.ProviderManager](ctx), overrideDialer: options.OverrideDialer, + overrideTLS: options.OverrideTLS, } filePath := filemanager.BasePath(ctx, options.Path) provider.path, _ = filepath.Abs(filePath) @@ -122,7 +124,7 @@ func (s *ProviderLocal) reloadFile(path string) error { if err != nil { return err } - outboundOpts, err := parser.ParseSubscription(s.ctx, string(content), s.overrideDialer, s.Tag()) + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content), s.overrideDialer, s.overrideTLS, s.Tag()) if err != nil { return err } diff --git a/provider/parser/parser.go b/provider/parser/parser.go index a89940e96b..5345ec00f8 100644 --- a/provider/parser/parser.go +++ b/provider/parser/parser.go @@ -17,19 +17,19 @@ var subscriptionParsers = []func(ctx context.Context, content string) ([]option. ParseRawSubscription, } -func ParseSubscription(ctx context.Context, content string, overrideDialerOptions *option.OverrideDialerOptions, providerTag string) ([]option.Outbound, error) { +func ParseSubscription(ctx context.Context, content string, overrideDialerOptions *option.OverrideDialerOptions, overrideTLSOptions *option.OverrideTLSOptions, providerTag string) ([]option.Outbound, error) { var pErr error for _, parser := range subscriptionParsers { servers, err := parser(ctx, content) if len(servers) > 0 { - return overrideOutbounds(servers, overrideDialerOptions, providerTag), nil + return overrideOutbounds(servers, overrideDialerOptions, overrideTLSOptions, providerTag), nil } pErr = E.Errors(pErr, err) } return nil, E.Cause(pErr, "no servers found") } -func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *option.OverrideDialerOptions, providerTag string) []option.Outbound { +func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *option.OverrideDialerOptions, overrideTLSOptions *option.OverrideTLSOptions, providerTag string) []option.Outbound { var tags []string for _, outbound := range outbounds { tags = append(tags, outbound.Tag) @@ -40,6 +40,7 @@ func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *optio case C.TypeHTTP: options := outbound.Options.(*option.HTTPOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeSOCKS: options := outbound.Options.(*option.SOCKSOutboundOptions) @@ -48,34 +49,42 @@ func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *optio case C.TypeTUIC: options := outbound.Options.(*option.TUICOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeVMess: options := outbound.Options.(*option.VMessOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeVLESS: options := outbound.Options.(*option.VLESSOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeTrojan: options := outbound.Options.(*option.TrojanOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeHysteria: options := outbound.Options.(*option.HysteriaOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeShadowTLS: options := outbound.Options.(*option.ShadowTLSOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeHysteria2: options := outbound.Options.(*option.Hysteria2OutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeAnyTLS: options := outbound.Options.(*option.AnyTLSOutboundOptions) options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) outbound.Options = options case C.TypeShadowsocks: options := outbound.Options.(*option.ShadowsocksOutboundOptions) @@ -170,3 +179,35 @@ func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *o } return options } + +func overrideTLSOption(options *option.OutboundTLSOptions, overrideTLSOptions *option.OverrideTLSOptions) *option.OutboundTLSOptions { + if options == nil { + return options + } + var defaultOptions option.OutboundTLSOptions + if overrideTLSOptions == nil || reflect.DeepEqual(*overrideTLSOptions, defaultOptions) { + return options + } + if overrideTLSOptions.Enabled != nil && !*overrideTLSOptions.Enabled { + return &defaultOptions + } + // if override.OverrideTLSOptions.Enabled != nil { + // options.Enabled = *override.OverrideTLSOptions.Enabled + // } + if overrideTLSOptions.DisableSNI != nil { + options.DisableSNI = *overrideTLSOptions.DisableSNI + } + if overrideTLSOptions.ServerName != nil { + options.ServerName = *overrideTLSOptions.ServerName + } + if overrideTLSOptions.Insecure != nil { + options.Insecure = *overrideTLSOptions.Insecure + } + if overrideTLSOptions.KernelTx != nil { + options.KernelTx = *overrideTLSOptions.KernelTx + } + if overrideTLSOptions.KernelRx != nil { + options.KernelRx = *overrideTLSOptions.KernelRx + } + return options +} diff --git a/provider/remote/remote.go b/provider/remote/remote.go index 3d94457142..010adf826f 100644 --- a/provider/remote/remote.go +++ b/provider/remote/remote.go @@ -68,6 +68,7 @@ type ProviderRemote struct { include *regexp.Regexp overrideDialer *option.OverrideDialerOptions + overrideTLS *option.OverrideTLSOptions } func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderRemoteOptions) (adapter.Provider, error) { @@ -117,6 +118,7 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo include: (*regexp.Regexp)(options.Include), overrideDialer: options.OverrideDialer, + overrideTLS: options.OverrideTLS, }, nil } @@ -418,7 +420,7 @@ func (s *ProviderRemote) saveCacheFile(hasInfo bool, info adapter.SubscriptionIn } func (s *ProviderRemote) updateProviderFromContent(content string) error { - outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer, s.Tag()) + outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer, s.overrideTLS, s.Tag()) if err != nil { return err } From 77bc1f04ea40495c75b2782fee1a4861be1aa7d0 Mon Sep 17 00:00:00 2001 From: DustinWin Date: Thu, 12 Mar 2026 14:23:00 +0800 Subject: [PATCH 57/57] =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=96=B0=E5=A2=9E=20re?= =?UTF-8?q?F1nd=20=E5=88=86=E6=94=AF=E7=89=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuration/experimental/index.md | 14 +-- docs/configuration/experimental/index.zh.md | 14 +-- .../experimental/urltest-unified-delay.md | 13 +++ .../experimental/urltest-unified-delay.zh.md | 13 +++ docs/configuration/outbound/fallback.md | 85 +++++++++++++++++++ docs/configuration/outbound/fallback.zh.md | 85 +++++++++++++++++++ docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/route/rule_action.md | 6 ++ docs/configuration/route/rule_action.zh.md | 6 ++ 10 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 docs/configuration/experimental/urltest-unified-delay.md create mode 100644 docs/configuration/experimental/urltest-unified-delay.zh.md create mode 100644 docs/configuration/outbound/fallback.md create mode 100644 docs/configuration/outbound/fallback.zh.md diff --git a/docs/configuration/experimental/index.md b/docs/configuration/experimental/index.md index a1a515cf85..c3785b8417 100644 --- a/docs/configuration/experimental/index.md +++ b/docs/configuration/experimental/index.md @@ -13,14 +13,16 @@ "cache_file": {}, "clash_api": {}, "v2ray_api": {} - } + }, + "urltest_unified_delay": true } ``` ### Fields -| Key | Format | -|--------------|----------------------------| -| `cache_file` | [Cache File](./cache-file/) | -| `clash_api` | [Clash API](./clash-api/) | -| `v2ray_api` | [V2Ray API](./v2ray-api/) | \ No newline at end of file +| Key | Format | +|-------------------------|-------------------------------------------| +| `cache_file` | [Cache File](./cache-file/) | +| `clash_api` | [Clash API](./clash-api/) | +| `v2ray_api` | [V2Ray API](./v2ray-api/) | +| `urltest_unified_delay` | [Unified Delay](./urltest-unified-delay/) | \ No newline at end of file diff --git a/docs/configuration/experimental/index.zh.md b/docs/configuration/experimental/index.zh.md index 01246c44ef..fbae161091 100644 --- a/docs/configuration/experimental/index.zh.md +++ b/docs/configuration/experimental/index.zh.md @@ -13,14 +13,16 @@ "cache_file": {}, "clash_api": {}, "v2ray_api": {} - } + }, + "urltest_unified_delay": true } ``` ### 字段 -| 键 | 格式 | -|--------------|--------------------------| -| `cache_file` | [缓存文件](./cache-file/) | -| `clash_api` | [Clash API](./clash-api/) | -| `v2ray_api` | [V2Ray API](./v2ray-api/) | \ No newline at end of file +| 键 | 格式 | +|-------------------------|--------------------------------------| +| `cache_file` | [缓存文件](./cache-file/) | +| `clash_api` | [Clash API](./clash-api/) | +| `v2ray_api` | [V2Ray API](./v2ray-api/) | +| `urltest_unified_delay` | [统一延迟](./urltest-unified-delay/) | \ No newline at end of file diff --git a/docs/configuration/experimental/urltest-unified-delay.md b/docs/configuration/experimental/urltest-unified-delay.md new file mode 100644 index 0000000000..ac15dbcf87 --- /dev/null +++ b/docs/configuration/experimental/urltest-unified-delay.md @@ -0,0 +1,13 @@ +### Structure + +```json +{ + "urltest_unified_delay": true +} +``` + +### Fields + +#### urltest_unified_delay + +When unified delay is enabled, two delay tests are conducted to eliminate latency differences caused by connection handshakes and other variations in different types of nodes. \ No newline at end of file diff --git a/docs/configuration/experimental/urltest-unified-delay.zh.md b/docs/configuration/experimental/urltest-unified-delay.zh.md new file mode 100644 index 0000000000..6d4f0c8619 --- /dev/null +++ b/docs/configuration/experimental/urltest-unified-delay.zh.md @@ -0,0 +1,13 @@ +### 结构 + +```json +{ + "urltest_unified_delay": true +} +``` + +### 字段 + +#### urltest_unified_delay + +开启统一延迟时,会计算 RTT,以消除连接握手等带来的不同类型节点的延迟差异。 \ No newline at end of file diff --git a/docs/configuration/outbound/fallback.md b/docs/configuration/outbound/fallback.md new file mode 100644 index 0000000000..37950a03fa --- /dev/null +++ b/docs/configuration/outbound/fallback.md @@ -0,0 +1,85 @@ +### Structure + +```json +{ + "type": "urltest", + "tag": "fallback", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "fallback": { + "enabled": true, + "max_delay": "200ms" + }, + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### outbounds + +List of outbound tags to test. + +#### providers + +List of [Provider](/configuration/provider) tags to test. + +#### fallback +If the current node times out, the first available node will be selected in the order of proxies. + +- `enabled` Indicates whether to enable Automatic rollback. + +- `max_delay` is an optional configuration. If a node is available but its delay exceeds this value, the node is considered unavailable, discarded, and the matching continues to select the next node. However, if all nodes are unavailable, but there is a node that has been eliminated by this rule, the node with the lowest delay is selected. + +#### exclude + +Exclude regular expression to filter `providers` nodes. + +#### include + +Include regular expression to filter `providers` nodes. + +#### url + +The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. + +#### interval + +The test interval. `3m` will be used if empty. + +#### idle_timeout + +The idle timeout. `30m` will be used if empty. + +#### ttl + +The time to live used for `sticky-sessions` strategy timeout. `10m` will be used if empty. + +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + +#### interrupt_exist_connections + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. diff --git a/docs/configuration/outbound/fallback.zh.md b/docs/configuration/outbound/fallback.zh.md new file mode 100644 index 0000000000..53c68bef95 --- /dev/null +++ b/docs/configuration/outbound/fallback.zh.md @@ -0,0 +1,85 @@ +### 结构 + +```json +{ + "type": "urltest", + "tag": "fallback", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "fallback": { + "enabled": true, + "max_delay": "200ms" + }, + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### 字段 + +#### outbounds + +用于测试的出站标签列表。 + +#### providers + +用于测试的[订阅](/zh/configuration/provider)标签列表。 + +#### fallback +当前节点超时时,则会按代理顺序选择第一个可用节点。 + +- `enabled` 是否开启自动回退。 + +- `max_delay` 为可选配置。若某节点可用,但是延迟超过该值,则认为该节点不可用,淘汰忽略该节点,继续匹配选择下一个节点,但若所有节点均不可用,但是存在被该规则淘汰的节点,则选择延迟最低的被淘汰节点。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + +#### url + +用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 + +#### interval + +测试间隔。 默认使用 `3m`。 + +#### idle_timeout + +空闲超时。默认使用 `30m`。 + +#### ttl + +用于 `sticky-sessions` 策略超时的生存时间。默认使用 `10m`。 + +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + +#### interrupt_exist_connections + +当选定的出站发生更改时,中断现有连接。 + +仅入站连接受此设置影响,内部连接将始终被中断。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 3998f7e744..79a4193ac1 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -38,6 +38,7 @@ | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | | `loadbalance` | [LoadBalance](./loadbalance/) | +| `fallback` | [FallBack](./fallback/) | #### tag diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index 73278459a0..d8c2292336 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -38,6 +38,7 @@ | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | | `loadbalance` | [LoadBalance](./loadbalance/) | +| `fallback` | [FallBack](./fallback/) | #### tag diff --git a/docs/configuration/route/rule_action.md b/docs/configuration/route/rule_action.md index 523ffec206..26adb49ae5 100644 --- a/docs/configuration/route/rule_action.md +++ b/docs/configuration/route/rule_action.md @@ -278,6 +278,7 @@ Timeout for sniffing. "action": "resolve", "server": "", "strategy": "", + "match_only": true, "disable_cache": false, "rewrite_ttl": null, "client_subnet": null @@ -296,6 +297,11 @@ DNS resolution strategy, available values are: `prefer_ipv4`, `prefer_ipv6`, `ip `dns.strategy` will be used by default. +#### match_only +Matching IP-related rules does not affect outbound domain name transmission. + +It allows for domestic and international traffic splitting with minimal configuration, and when used with fakeip, it does not affect the sending of domain names to proxy servers. + #### disable_cache !!! question "Since sing-box 1.12.0" diff --git a/docs/configuration/route/rule_action.zh.md b/docs/configuration/route/rule_action.zh.md index 16efb53a8d..2f5bc13c85 100644 --- a/docs/configuration/route/rule_action.zh.md +++ b/docs/configuration/route/rule_action.zh.md @@ -267,6 +267,7 @@ UDP 连接超时时间。 "action": "resolve", "server": "", "strategy": "", + "match_only": true, "disable_cache": false, "rewrite_ttl": null, "client_subnet": null @@ -285,6 +286,11 @@ DNS 解析策略,可用值有:`prefer_ipv4`、`prefer_ipv6`、`ipv4_only`、 默认使用 `dns.strategy`。 +#### match_only +匹配 IP 类规则,不影响出站传递域名。 + +可以用最小配置实现国内外的分流,配合 fakeip 的情况下不影响向代理服务器发送域名。 + #### disable_cache !!! question "自 sing-box 1.12.0 起"