From 15a9be9a1d73aa665b0c11bfe0aff44460460569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 14:44:43 +0800 Subject: [PATCH] Add tailcat support --- adapter/router.go | 12 +- cmd/sing-box/cmd_generate_tailcat.go | 42 ++ constant/proxy.go | 3 + docs/configuration/inbound/index.md | 1 + docs/configuration/inbound/index.zh.md | 1 + docs/configuration/inbound/tailcat.md | 98 ++++ docs/configuration/inbound/tailcat.zh.md | 96 ++++ docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/outbound/tailcat.md | 84 +++ docs/configuration/outbound/tailcat.zh.md | 84 +++ docs/configuration/service/derp.md | 14 + docs/configuration/service/derp.zh.md | 14 + docs/schema.json | 384 +++++++++++++ go.mod | 2 +- include/registry.go | 2 + include/tailscale.go | 10 + include/tailscale_stub.go | 14 + mkdocs.yml | 2 + option/tailcat.go | 74 +++ option/tailscale.go | 2 + protocol/openconnect/endpoint.go | 2 +- protocol/openvpn/endpoint.go | 2 +- protocol/tailscale/endpoint.go | 148 +---- protocol/tailscale/port.go | 2 +- protocol/tailscale/system_binding.go | 149 +++++ protocol/tailscale/tailcat_derp.go | 171 ++++++ protocol/tailscale/tailcat_disco.go | 101 ++++ protocol/tailscale/tailcat_inbound.go | 248 +++++++++ protocol/tailscale/tailcat_key.go | 74 +++ protocol/tailscale/tailcat_node.go | 639 ++++++++++++++++++++++ protocol/tailscale/tailcat_outbound.go | 438 +++++++++++++++ protocol/tun/inbound.go | 2 +- protocol/wireguard/endpoint.go | 2 +- service/derp/service.go | 40 ++ test/go.mod | 10 +- test/go.sum | 10 +- test/tailcat_test.go | 288 ++++++++++ 38 files changed, 3108 insertions(+), 159 deletions(-) create mode 100644 cmd/sing-box/cmd_generate_tailcat.go create mode 100644 docs/configuration/inbound/tailcat.md create mode 100644 docs/configuration/inbound/tailcat.zh.md create mode 100644 docs/configuration/outbound/tailcat.md create mode 100644 docs/configuration/outbound/tailcat.zh.md create mode 100644 option/tailcat.go create mode 100644 protocol/tailscale/system_binding.go create mode 100644 protocol/tailscale/tailcat_derp.go create mode 100644 protocol/tailscale/tailcat_disco.go create mode 100644 protocol/tailscale/tailcat_inbound.go create mode 100644 protocol/tailscale/tailcat_key.go create mode 100644 protocol/tailscale/tailcat_node.go create mode 100644 protocol/tailscale/tailcat_outbound.go create mode 100644 test/tailcat_test.go diff --git a/adapter/router.go b/adapter/router.go index be6267419..ae9c1dc42 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -49,7 +49,7 @@ type PreMatchResult struct { NewTracker func() tun.FlowTracker } -func JudgeFlow(router Router, inbound string, inboundType string, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { +func JudgeFlow(router Router, metadata InboundContext, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { var networkName string switch network { case uint8(header.TCPProtocolNumber): @@ -61,13 +61,9 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8, default: return tun.FlowVerdict{Action: tun.ActionAccept} } - metadata := InboundContext{ - Inbound: inbound, - InboundType: inboundType, - Network: networkName, - Source: M.SocksaddrFromNetIP(source), - Destination: M.SocksaddrFromNetIP(destination), - } + metadata.Network = networkName + metadata.Source = M.SocksaddrFromNetIP(source) + metadata.Destination = M.SocksaddrFromNetIP(destination) if networkName == N.NetworkICMP { metadata.Source.Port = 0 metadata.Destination.Port = 0 diff --git a/cmd/sing-box/cmd_generate_tailcat.go b/cmd/sing-box/cmd_generate_tailcat.go new file mode 100644 index 000000000..45acb3dae --- /dev/null +++ b/cmd/sing-box/cmd_generate_tailcat.go @@ -0,0 +1,42 @@ +package main + +import ( + "crypto/rand" + "os" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/protocol/tailscale" + + "github.com/spf13/cobra" +) + +func init() { + commandGenerate.AddCommand(commandGenerateTailcatKeyPair) +} + +var commandGenerateTailcatKeyPair = &cobra.Command{ + Use: "tailcat-keypair", + Short: "Generate Tailcat key pair", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := generateTailcatKey() + if err != nil { + log.Fatal(err) + } + }, +} + +func generateTailcatKey() error { + privateKey := tailscale.NewTailcatPrivateKey() + var presharedKey [32]byte + _, err := rand.Read(presharedKey[:]) + if err != nil { + return err + } + os.Stdout.WriteString("PrivateKey: " + tailscale.EncodeTailcatKey(privateKey) + "\n") + os.Stdout.WriteString("PublicKey: " + tailscale.EncodeTailcatKey(tailscale.TailcatPublicKey(privateKey)) + "\n") + os.Stdout.WriteString("DiscoKey: " + tailscale.EncodeTailcatKey(tailscale.TailcatPublicKey(tailscale.TailcatDiscoPrivateKey(privateKey))) + "\n") + os.Stdout.WriteString("PreSharedKey: " + tailscale.EncodeTailcatKey(presharedKey) + "\n") + os.Stdout.WriteString("Address: " + tailscale.TailcatAddress(tailscale.TailcatPublicKey(privateKey)).String() + "\n") + return nil +} diff --git a/constant/proxy.go b/constant/proxy.go index d42e5d92b..4d4eaea7a 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -30,6 +30,7 @@ const ( TypeOpenVPNClient = "openvpn-client" TypeOpenVPNServer = "openvpn-server" TypeTailscale = "tailscale" + TypeTailcat = "tailcat" TypeCloudflared = "cloudflared" TypeDERP = "derp" TypeResolved = "resolved" @@ -110,6 +111,8 @@ func ProxyDisplayName(proxyType string) string { return "OpenVPN Server" case TypeTailscale: return "Tailscale" + case TypeTailcat: + return "Tailcat" case TypeCloudflared: return "Cloudflared" case TypeSelector: diff --git a/docs/configuration/inbound/index.md b/docs/configuration/inbound/index.md index 093db6524..a21dbae4a 100644 --- a/docs/configuration/inbound/index.md +++ b/docs/configuration/inbound/index.md @@ -36,6 +36,7 @@ | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | | `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: | +| `tailcat` | [Tailcat](./tailcat/) | :material-close: | #### tag diff --git a/docs/configuration/inbound/index.zh.md b/docs/configuration/inbound/index.zh.md index 0f73ae957..addfbe78b 100644 --- a/docs/configuration/inbound/index.zh.md +++ b/docs/configuration/inbound/index.zh.md @@ -36,6 +36,7 @@ | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | | `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: | +| `tailcat` | [Tailcat](./tailcat/) | :material-close: | #### tag diff --git a/docs/configuration/inbound/tailcat.md b/docs/configuration/inbound/tailcat.md new file mode 100644 index 000000000..193388be0 --- /dev/null +++ b/docs/configuration/inbound/tailcat.md @@ -0,0 +1,98 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.15.0" + +### Structure + +```json +{ + "type": "tailcat", + "tag": "tailcat-in", + + "private_key": "", + "pre_shared_key": "", + "users": [ + { + "name": "", + "public_key": "" + } + ], + "derp_map_url": "", + "derp_region": 0, + "derp_servers": [], + "http_client": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### private_key + +==Required== + +Private key. + +Generate with `sing-box generate tailcat-keypair`. + +#### pre_shared_key + +Pre-shared key. + +#### users + +Tailcat users. + +Clients are not verified if empty. + +To use a DERP server with client verification, set `verify_client_inbound` or `verify_client_key` in +[DERP service](/configuration/service/derp/#verify_client_inbound), and clients must use fixed +private keys. + +#### users.public_key + +==Required== + +Client public key. + +#### derp_map_url + +URL of the [DERP map](https://pkg.go.dev/tailscale.com/tailcfg#DERPMap). + +`https://tailcat.dev/derpmap.json` is used by default. + +#### derp_region + +DERP region ID in the DERP map. + +Conflicts with `derp_servers`. + +#### derp_servers + +Custom DERP servers in [DERPNode](https://pkg.go.dev/tailscale.com/tailcfg#DERPNode) format, with +snake_case field names. + +Conflicts with `derp_map_url` and `derp_region`. + +Setting Array value to a string `__HOST__` is equivalent to configuring: + +```json +{ "host": __HOST__ } +``` + +#### http_client + +HTTP client used to fetch the DERP map. + +See [HTTP Client](/configuration/shared/http-client/) for details. + +### Dial Fields + +!!! note + + Dial Fields in Tailcat inbounds only control how it connects to DERP servers and have nothing to do with actual connections. + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/inbound/tailcat.zh.md b/docs/configuration/inbound/tailcat.zh.md new file mode 100644 index 000000000..2928597b2 --- /dev/null +++ b/docs/configuration/inbound/tailcat.zh.md @@ -0,0 +1,96 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.15.0 起" + +### 结构 + +```json +{ + "type": "tailcat", + "tag": "tailcat-in", + + "private_key": "", + "pre_shared_key": "", + "users": [ + { + "name": "", + "public_key": "" + } + ], + "derp_map_url": "", + "derp_region": 0, + "derp_servers": [], + "http_client": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### private_key + +==必填== + +私钥。 + +使用 `sing-box generate tailcat-keypair` 生成。 + +#### pre_shared_key + +预共享密钥。 + +#### users + +Tailcat 用户。 + +为空时不验证客户端。 + +如需使用带客户端验证的 DERP 服务器,在 [DERP 服务](/zh/configuration/service/derp/#verify_client_inbound) +中设置 `verify_client_inbound` 或 `verify_client_key`,且客户端必须使用固定的私钥。 + +#### users.public_key + +==必填== + +客户端公钥。 + +#### derp_map_url + +[DERP map](https://pkg.go.dev/tailscale.com/tailcfg#DERPMap) 的 URL。 + +默认使用 `https://tailcat.dev/derpmap.json`。 + +#### derp_region + +DERP map 中的 DERP 区域 ID。 + +与 `derp_servers` 冲突。 + +#### derp_servers + +自定义 DERP 服务器,为 [DERPNode](https://pkg.go.dev/tailscale.com/tailcfg#DERPNode) 格式,字段名使用 snake_case。 + +与 `derp_map_url` 和 `derp_region` 冲突。 + +将数组值设置为字符串 `__HOST__` 等同于配置: + +```json +{ "host": __HOST__ } +``` + +#### http_client + +用于获取 DERP map 的 HTTP 客户端。 + +参阅 [HTTP 客户端](/zh/configuration/shared/http-client/) 了解详情。 + +### 拨号字段 + +!!! note + + Tailcat 入站中的拨号字段仅控制它如何连接到 DERP 服务器,与实际连接无关。 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 2e37bdd58..10b42e5b1 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -33,6 +33,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | | `anytls` | [AnyTLS](./anytls/) | | `snell` | [Snell](./snell/) | +| `tailcat` | [Tailcat](./tailcat/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index d2203de8c..39da156a4 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -33,6 +33,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | | `anytls` | [AnyTLS](./anytls/) | | `snell` | [Snell](./snell/) | +| `tailcat` | [Tailcat](./tailcat/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/tailcat.md b/docs/configuration/outbound/tailcat.md new file mode 100644 index 000000000..bc4d7b200 --- /dev/null +++ b/docs/configuration/outbound/tailcat.md @@ -0,0 +1,84 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.15.0" + +### Structure + +```json +{ + "type": "tailcat", + "tag": "tailcat-out", + + "private_key": "", + "server_public_key": "", + "server_disco_key": "", + "pre_shared_key": "", + "derp_map_url": "", + "derp_region": 0, + "derp_servers": [], + "http_client": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### private_key + +Private key. + +A random key is used by default. + +Required when the server verifies clients with `users`, or the DERP server verifies clients with +`verify_client_inbound` or `verify_client_key`. + +#### server_public_key + +==Required== + +Server public key. + +#### server_disco_key + +==Required== + +Server disco public key. + +#### pre_shared_key + +Pre-shared key. + +#### derp_map_url + +URL of the [DERP map](https://pkg.go.dev/tailscale.com/tailcfg#DERPMap). + +`https://tailcat.dev/derpmap.json` is used by default. + +#### derp_region + +DERP region ID in the DERP map. + +Conflicts with `derp_servers`. + +#### derp_servers + +Custom DERP servers, see [derp_servers](/configuration/inbound/tailcat/#derp_servers) in Tailcat inbound. + +Conflicts with `derp_map_url` and `derp_region`. + +#### http_client + +HTTP client used to fetch the DERP map. + +See [HTTP Client](/configuration/shared/http-client/) for details. + +### Dial Fields + +!!! note + + Dial Fields in Tailcat outbounds only control how it connects to DERP servers and have nothing to do with actual connections. + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/tailcat.zh.md b/docs/configuration/outbound/tailcat.zh.md new file mode 100644 index 000000000..5cbbabb13 --- /dev/null +++ b/docs/configuration/outbound/tailcat.zh.md @@ -0,0 +1,84 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.15.0 起" + +### 结构 + +```json +{ + "type": "tailcat", + "tag": "tailcat-out", + + "private_key": "", + "server_public_key": "", + "server_disco_key": "", + "pre_shared_key": "", + "derp_map_url": "", + "derp_region": 0, + "derp_servers": [], + "http_client": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### private_key + +私钥。 + +默认使用随机密钥。 + +当服务器通过 `users` 验证客户端,或 DERP 服务器通过 `verify_client_inbound` 或 `verify_client_key` +验证客户端时必须设置。 + +#### server_public_key + +==必填== + +服务器公钥。 + +#### server_disco_key + +==必填== + +服务器 disco 公钥。 + +#### pre_shared_key + +预共享密钥。 + +#### derp_map_url + +[DERP map](https://pkg.go.dev/tailscale.com/tailcfg#DERPMap) 的 URL。 + +默认使用 `https://tailcat.dev/derpmap.json`。 + +#### derp_region + +DERP map 中的 DERP 区域 ID。 + +与 `derp_servers` 冲突。 + +#### derp_servers + +自定义 DERP 服务器,参阅 Tailcat 入站的 [derp_servers](/zh/configuration/inbound/tailcat/#derp_servers)。 + +与 `derp_map_url` 和 `derp_region` 冲突。 + +#### http_client + +用于获取 DERP map 的 HTTP 客户端。 + +参阅 [HTTP 客户端](/zh/configuration/shared/http-client/) 了解详情。 + +### 拨号字段 + +!!! note + + Tailcat 出站中的拨号字段仅控制它如何连接到 DERP 服务器,与实际连接无关。 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 diff --git a/docs/configuration/service/derp.md b/docs/configuration/service/derp.md index 2925db993..80c66e24e 100644 --- a/docs/configuration/service/derp.md +++ b/docs/configuration/service/derp.md @@ -20,6 +20,8 @@ DERP service is a Tailscale DERP server, similar to [derper](https://pkg.go.dev/ "config_path": "", "verify_client_endpoint": [], "verify_client_url": [], + "verify_client_inbound": [], + "verify_client_key": [], "home": "", "mesh_with": [], "mesh_psk": "", @@ -70,6 +72,18 @@ Setting Array value to a string `__URL__` is equivalent to configuring: { "url": __URL__ } ``` +#### verify_client_inbound + +!!! question "Since sing-box 1.15.0" + +Tailcat inbound tags to verify clients. + +#### verify_client_key + +!!! question "Since sing-box 1.15.0" + +Tailcat public keys to verify clients. + #### home What to serve at the root path. It may be left empty (the default, for a default homepage), `blank` for a blank page, or a URL to redirect to diff --git a/docs/configuration/service/derp.zh.md b/docs/configuration/service/derp.zh.md index 01e2ac39f..1f05cb331 100644 --- a/docs/configuration/service/derp.zh.md +++ b/docs/configuration/service/derp.zh.md @@ -20,6 +20,8 @@ DERP 服务是一个 Tailscale DERP 服务器,类似于 [derper](https://pkg.g "config_path": "", "verify_client_endpoint": [], "verify_client_url": [], + "verify_client_inbound": [], + "verify_client_key": [], "home": "", "mesh_with": [], "mesh_psk": "", @@ -70,6 +72,18 @@ Derper 配置文件路径。 { "url": __URL__ } ``` +#### verify_client_inbound + +!!! question "自 sing-box 1.15.0 起" + +用于验证客户端的 Tailcat 入站标签。 + +#### verify_client_key + +!!! question "自 sing-box 1.15.0 起" + +用于验证客户端的 Tailcat 公钥。 + #### home 在根路径提供的内容。可以留空(默认值,显示默认主页)、`blank` 显示空白页面,或一个重定向的 URL。 diff --git a/docs/schema.json b/docs/schema.json index aa0c5056e..e53d36059 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -7169,6 +7169,179 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "const": "tailcat" + }, + "tag": { + "type": "string" + }, + "private_key": { + "type": "string" + }, + "pre_shared_key": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/TailcatUser" + } + }, + "derp_map_url": { + "type": "string" + }, + "derp_region": { + "type": "integer" + }, + "derp_servers": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "ipv4": { + "type": "string" + }, + "ipv6": { + "type": "string" + }, + "derp_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "stun_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "cert_name": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -12842,6 +13015,179 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "const": "tailcat" + }, + "tag": { + "type": "string" + }, + "private_key": { + "type": "string" + }, + "server_public_key": { + "type": "string" + }, + "server_disco_key": { + "type": "string" + }, + "pre_shared_key": { + "type": "string" + }, + "derp_map_url": { + "type": "string" + }, + "derp_region": { + "type": "integer" + }, + "derp_servers": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "ipv4": { + "type": "string" + }, + "ipv6": { + "type": "string" + }, + "derp_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "stun_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "cert_name": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -15824,6 +16170,32 @@ } ] }, + "verify_client_inbound": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "verify_client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "home": { "type": "string" }, @@ -16793,6 +17165,18 @@ }, "additionalProperties": false }, + "TailcatUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "public_key": { + "type": "string" + } + }, + "additionalProperties": false + }, "TrojanUser": { "type": "object", "properties": { diff --git a/go.mod b/go.mod index 51a9d85c2..8f44e15c9 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 github.com/vishvananda/netns v0.0.5 go.uber.org/zap v1.27.1 + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba golang.org/x/crypto v0.54.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f @@ -187,7 +188,6 @@ require ( github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect - go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect diff --git a/include/registry.go b/include/registry.go index 9bb9220c1..ac817fff2 100644 --- a/include/registry.go +++ b/include/registry.go @@ -72,6 +72,7 @@ func InboundRegistry() *inbound.Registry { registerQUICInbounds(registry) registerCloudflaredInbound(registry) + registerTailcatInbound(registry) registerStubForRemovedInbounds(registry) return registry @@ -102,6 +103,7 @@ func OutboundRegistry() *outbound.Registry { anytls.RegisterOutbound(registry) registerQUICOutbounds(registry) + registerTailcatOutbound(registry) registerStubForRemovedOutbounds(registry) return registry diff --git a/include/tailscale.go b/include/tailscale.go index 6f85aaac1..8ef2ba173 100644 --- a/include/tailscale.go +++ b/include/tailscale.go @@ -5,6 +5,8 @@ package include import ( "github.com/sagernet/sing-box/adapter/certificate" "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/service" "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/protocol/tailscale" @@ -15,6 +17,14 @@ func registerTailscaleEndpoint(registry *endpoint.Registry) { tailscale.RegisterEndpoint(registry) } +func registerTailcatInbound(registry *inbound.Registry) { + tailscale.RegisterTailcatInbound(registry) +} + +func registerTailcatOutbound(registry *outbound.Registry) { + tailscale.RegisterTailcatOutbound(registry) +} + func registerTailscaleTransport(registry *dns.TransportRegistry) { tailscale.RegistryTransport(registry) } diff --git a/include/tailscale_stub.go b/include/tailscale_stub.go index e6f97f1ea..21d520ae0 100644 --- a/include/tailscale_stub.go +++ b/include/tailscale_stub.go @@ -8,6 +8,8 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/certificate" "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/service" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -22,6 +24,18 @@ func registerTailscaleEndpoint(registry *endpoint.Registry) { }) } +func registerTailcatInbound(registry *inbound.Registry) { + inbound.Register[option.TailcatInboundOptions](registry, C.TypeTailcat, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailcatInboundOptions) (adapter.Inbound, error) { + return nil, E.New(`Tailcat is not included in this build, rebuild with -tags with_tailscale`) + }) +} + +func registerTailcatOutbound(registry *outbound.Registry) { + outbound.Register[option.TailcatOutboundOptions](registry, C.TypeTailcat, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailcatOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`Tailcat is not included in this build, rebuild with -tags with_tailscale`) + }) +} + func registerTailscaleTransport(registry *dns.TransportRegistry) { dns.RegisterTransport[option.TailscaleDNSServerOptions](registry, C.DNSTypeTailscale, func(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleDNSServerOptions) (adapter.DNSTransport, error) { return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) diff --git a/mkdocs.yml b/mkdocs.yml index 708f901f2..994992ab1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -179,6 +179,7 @@ nav: - Redirect: configuration/inbound/redirect.md - TProxy: configuration/inbound/tproxy.md - Cloudflared: configuration/inbound/cloudflared.md + - Tailcat: configuration/inbound/tailcat.md - Outbound: - configuration/outbound/index.md - Direct: configuration/outbound/direct.md @@ -198,6 +199,7 @@ nav: - Hysteria2: configuration/outbound/hysteria2.md - AnyTLS: configuration/outbound/anytls.md - Snell: configuration/outbound/snell.md + - Tailcat: configuration/outbound/tailcat.md - Tor: configuration/outbound/tor.md - SSH: configuration/outbound/ssh.md - DNS: configuration/outbound/dns.md diff --git a/option/tailcat.go b/option/tailcat.go new file mode 100644 index 000000000..89455fc71 --- /dev/null +++ b/option/tailcat.go @@ -0,0 +1,74 @@ +package option + +import ( + "reflect" + + "github.com/sagernet/sing-box/schema" + "github.com/sagernet/sing/common/json" +) + +type TailcatDERPOptions struct { + DERPMapURL string `json:"derp_map_url,omitempty"` + DERPRegion int `json:"derp_region,omitempty"` + DERPServers []TailcatDERPServer `json:"derp_servers,omitempty"` + HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` +} + +type _TailcatDERPServer struct { + Host string `json:"host"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + DERPPort uint16 `json:"derp_port,omitempty"` + STUNPort uint16 `json:"stun_port,omitempty"` + CertName string `json:"cert_name,omitempty"` +} + +type TailcatDERPServer _TailcatDERPServer + +func (s TailcatDERPServer) MarshalJSON() ([]byte, error) { + if s == (TailcatDERPServer{Host: s.Host}) { + return json.Marshal(s.Host) + } + return json.Marshal(_TailcatDERPServer(s)) +} + +func (s *TailcatDERPServer) UnmarshalJSON(bytes []byte) error { + var host string + err := json.Unmarshal(bytes, &host) + if err == nil { + *s = TailcatDERPServer{Host: host} + return nil + } + return json.UnmarshalDisallowUnknownFields(bytes, (*_TailcatDERPServer)(s)) +} + +func (s TailcatDERPServer) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[TailcatDERPServer]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.StringNode(), objectForm), nil +} + +type TailcatUser struct { + Name string `json:"name,omitempty"` + PublicKey string `json:"public_key"` +} + +type TailcatInboundOptions struct { + PrivateKey string `json:"private_key"` + PreSharedKey string `json:"pre_shared_key,omitempty"` + Users []TailcatUser `json:"users,omitempty"` + TailcatDERPOptions + DialerOptions +} + +type TailcatOutboundOptions struct { + PrivateKey string `json:"private_key,omitempty"` + ServerPublicKey string `json:"server_public_key"` + ServerDiscoKey string `json:"server_disco_key"` + PreSharedKey string `json:"pre_shared_key,omitempty"` + TailcatDERPOptions + DialerOptions +} diff --git a/option/tailscale.go b/option/tailscale.go index ab7696ae5..1ae6d6b4a 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -86,6 +86,8 @@ type DERPServiceOptions struct { ConfigPath string `json:"config_path,omitempty"` VerifyClientEndpoint badoption.Listable[string] `json:"verify_client_endpoint,omitempty"` VerifyClientURL badoption.Listable[*DERPVerifyClientURLOptions] `json:"verify_client_url,omitempty"` + VerifyClientInbound badoption.Listable[string] `json:"verify_client_inbound,omitempty"` + VerifyClientKey badoption.Listable[string] `json:"verify_client_key,omitempty"` Home string `json:"home,omitempty"` MeshWith badoption.Listable[*DERPMeshOptions] `json:"mesh_with,omitempty"` MeshPSK string `json:"mesh_psk,omitempty"` diff --git a/protocol/openconnect/endpoint.go b/protocol/openconnect/endpoint.go index b7915c618..2b726218f 100644 --- a/protocol/openconnect/endpoint.go +++ b/protocol/openconnect/endpoint.go @@ -97,7 +97,7 @@ func judgeOpenConnectFlow(router adapter.Router, tag string, endpointType string return tun.FlowVerdict{Action: tun.ActionAccept} } } - return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket) + return adapter.JudgeFlow(router, adapter.InboundContext{Inbound: tag, InboundType: endpointType}, network, source, destination, firstPacket) } func materialSource(name string, inlineValues []string, path string) (openconnect.Material, error) { diff --git a/protocol/openvpn/endpoint.go b/protocol/openvpn/endpoint.go index 33cd43a1f..6d238daf5 100644 --- a/protocol/openvpn/endpoint.go +++ b/protocol/openvpn/endpoint.go @@ -103,7 +103,7 @@ func judgeOpenVPNFlow(router adapter.Router, tag string, endpointType string, lo return tun.FlowVerdict{Action: tun.ActionAccept} } } - return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket) + return adapter.JudgeFlow(router, adapter.InboundContext{Inbound: tag, InboundType: endpointType}, network, source, destination, firstPacket) } func keyDirectionValue(direction string) (int, error) { diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index e536beec9..064dcd6ea 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -16,7 +16,6 @@ import ( "strings" "sync" "sync/atomic" - "syscall" "time" "github.com/sagernet/sing-box/adapter" @@ -33,7 +32,6 @@ import ( "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" - "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/logger" @@ -48,13 +46,10 @@ import ( "github.com/sagernet/tailscale/ipn/ipnlocal" tsDNS "github.com/sagernet/tailscale/net/dns" nDNSResolver "github.com/sagernet/tailscale/net/dns/resolver" - "github.com/sagernet/tailscale/net/netmon" - "github.com/sagernet/tailscale/net/netns" "github.com/sagernet/tailscale/net/tsaddr" tsTUN "github.com/sagernet/tailscale/net/tstun" "github.com/sagernet/tailscale/tailcfg" "github.com/sagernet/tailscale/tsnet" - "github.com/sagernet/tailscale/types/nettype" "github.com/sagernet/tailscale/version" "github.com/sagernet/tailscale/wgengine" "github.com/sagernet/tailscale/wgengine/router" @@ -91,6 +86,7 @@ type Endpoint struct { network adapter.NetworkManager platformInterface adapter.PlatformInterface detour string + binding systemBinding server *tsnet.Server stack *tun.Go returnAccess sync.Mutex @@ -277,52 +273,14 @@ func (t *Endpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { } func (t *Endpoint) start(scope *adapter.Scope) error { - if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { - err := t.network.UpdateInterfaces() - if err != nil { - return err - } - } - if interfaceMonitor := t.network.InterfaceMonitor(); interfaceMonitor != nil { - interfaceFinder := t.network.InterfaceFinder() - netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) { - return common.Map(interfaceFinder.Interfaces(), func(it control.Interface) netmon.Interface { - return netmon.Interface{ - Interface: &net.Interface{ - Index: it.Index, - MTU: it.MTU, - Name: it.Name, - HardwareAddr: it.HardwareAddr, - Flags: it.Flags, - }, - AltAddrs: common.Map(it.Addresses, func(it netip.Prefix) net.Addr { - return &net.IPNet{ - IP: it.Addr().AsSlice(), - Mask: net.CIDRMask(it.Bits(), it.Addr().BitLen()), - } - }), - } - }), nil - }) - scope.Add(func() error { - netmon.RegisterInterfaceGetter(nil) - return nil - }) - netmon.SetDefaultRouteFunc(func() (netmon.DefaultRouteDetails, error) { - defaultInterface := interfaceMonitor.DefaultInterface() - if defaultInterface == nil { - return netmon.DefaultRouteDetails{}, nil - } - return netmon.DefaultRouteDetails{ - InterfaceName: defaultInterface.Name, - InterfaceIndex: defaultInterface.Index, - }, nil - }) - scope.Add(func() error { - netmon.SetDefaultRouteFunc(nil) - return nil - }) + binding, err := newSystemBinding(t.ctx, t.logger) + if err != nil { + return err } + t.binding = binding + t.server.ControlFunc = binding.control + t.server.ListenPacketFunc = binding.listenPacket + t.server.DefaultRouteFunc = binding.defaultRoute if t.systemInterface { mtu := t.systemInterfaceMTU if mtu == 0 { @@ -367,100 +325,20 @@ func (t *Endpoint) start(scope *adapter.Scope) error { t.systemDialer = systemDialer t.server.Tun = wgTunDevice } - if t.network.AutoRedirectOutputMark() != 0 { - netns.SetControlFunc(t.network.AutoRedirectOutputMarkFunc()) - scope.Add(func() error { - netns.SetControlFunc(nil) - return nil - }) - } else if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { - if t.platformInterface.UsePlatformAutoDetectInterfaceControl() { - netns.SetControlFunc(func(network, address string, conn syscall.RawConn) error { - return control.Raw(conn, func(fileDescriptor uintptr) error { - return t.platformInterface.AutoDetectInterfaceControl(int(fileDescriptor)) - }) - }) - scope.Add(func() error { - netns.SetControlFunc(nil) - return nil - }) - } else { - // NEPacketTunnelProvider sockets are excluded from tunnel routes by - // NECP; the empty override only suppresses tailscale's own - // default-interface bind, which would select the sing-box utun. - netns.SetControlFunc(func(string, string, syscall.RawConn) error { - return nil - }) - scope.Add(func() error { - netns.SetControlFunc(nil) - return nil - }) - } - } else { - bindFunc := t.network.AutoDetectInterfaceFunc() - if bindFunc != nil { - netns.SetControlFunc(bindFunc) - netns.SetListenPacketFunc(t.listenPacket) - scope.Add(func() error { - netns.SetControlFunc(nil) - netns.SetListenPacketFunc(nil) - return nil - }) - } - } return nil } -func (t *Endpoint) listenPacket(ctx context.Context, network string, address string) (nettype.PacketConn, error) { - listenConfig := net.ListenConfig{ - Control: control.Append(t.network.AutoDetectInterfaceFunc(), control.DisableUDPNetReset()), - } - packetConn, err := listenConfig.ListenPacket(ctx, network, address) - if err != nil { - return nil, err - } - udpConn := packetConn.(*net.UDPConn) - egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ - Logger: t.logger, - Network: network, - InterfaceFinder: t.network.InterfaceFinder(), - InterfaceMonitor: t.network.InterfaceMonitor(), - IsExempt: func() bool { - return t.network.AutoRedirectOutputMark() != 0 - }, - }) - if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) { - egressPool.Close() - return udpConn, nil - } - return tun.NewUDPEgressConn(udpConn, egressPool), nil -} - func (t *Endpoint) postStart(scope *adapter.Scope) error { err := t.server.Start() if err != nil { return err } scope.Add(t.server.Close) - if t.network.InterfaceMonitor() != nil { - netMon := t.server.Sys().NetMon.Get() - interfaceFinder := t.network.InterfaceFinder() - var interfacesAccess sync.Mutex - lastInterfaces := interfaceFinder.Interfaces() - interfaceUpdateElement := interfaceFinder.RegisterInterfaceUpdateCallback(func(interfaces []control.Interface) { - interfacesAccess.Lock() - changed := !slices.EqualFunc(lastInterfaces, interfaces, control.Interface.Equals) - lastInterfaces = interfaces - interfacesAccess.Unlock() - if changed { - netMon.Poll() - } - }) - scope.Add(func() error { - interfaceFinder.UnregisterInterfaceUpdateCallback(interfaceUpdateElement) - return nil - }) - } + unwatch := t.binding.watch(t.server.Sys().NetMon.Get()) + scope.Add(func() error { + unwatch() + return nil + }) localBackend := t.server.ExportLocalBackend() t.localBackend.Store(localBackend) scope.Add(func() error { diff --git a/protocol/tailscale/port.go b/protocol/tailscale/port.go index 0483ef2ea..d3bfc9577 100644 --- a/protocol/tailscale/port.go +++ b/protocol/tailscale/port.go @@ -68,7 +68,7 @@ func (t *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n } } } - return adapter.JudgeFlow(t.router, t.Tag(), t.Type(), network, source, destination, firstPacket) + return adapter.JudgeFlow(t.router, adapter.InboundContext{Inbound: t.Tag(), InboundType: t.Type()}, network, source, destination, firstPacket) } func (t *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { diff --git a/protocol/tailscale/system_binding.go b/protocol/tailscale/system_binding.go new file mode 100644 index 000000000..916a11a85 --- /dev/null +++ b/protocol/tailscale/system_binding.go @@ -0,0 +1,149 @@ +//go:build with_tailscale + +package tailscale + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/nettype" +) + +type systemBinding struct { + interfaceFinder control.InterfaceFinder + control func(network, address string, conn syscall.RawConn) error + listenPacket func(ctx context.Context, network, address string) (nettype.PacketConn, error) + defaultRoute func() (netmon.DefaultRouteDetails, error) +} + +func newSystemBinding(ctx context.Context, logger logger.ContextLogger) (systemBinding, error) { + network := service.FromContext[adapter.NetworkManager](ctx) + platformInterface := service.FromContext[adapter.PlatformInterface](ctx) + var binding systemBinding + if platformInterface != nil && platformInterface.UsePlatformNetworkInterfaces() { + err := network.UpdateInterfaces() + if err != nil { + return systemBinding{}, err + } + } + if interfaceMonitor := network.InterfaceMonitor(); interfaceMonitor != nil { + interfaceFinder := network.InterfaceFinder() + netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) { + return common.Map(interfaceFinder.Interfaces(), func(it control.Interface) netmon.Interface { + return netmon.Interface{ + Interface: &net.Interface{ + Index: it.Index, + MTU: it.MTU, + Name: it.Name, + HardwareAddr: it.HardwareAddr, + Flags: it.Flags, + }, + AltAddrs: common.Map(it.Addresses, func(it netip.Prefix) net.Addr { + return &net.IPNet{ + IP: it.Addr().AsSlice(), + Mask: net.CIDRMask(it.Bits(), it.Addr().BitLen()), + } + }), + } + }), nil + }) + binding.interfaceFinder = interfaceFinder + binding.defaultRoute = func() (netmon.DefaultRouteDetails, error) { + defaultInterface := interfaceMonitor.DefaultInterface() + if defaultInterface == nil { + return netmon.DefaultRouteDetails{}, nil + } + return netmon.DefaultRouteDetails{ + InterfaceName: defaultInterface.Name, + InterfaceIndex: defaultInterface.Index, + }, nil + } + } + if network.AutoRedirectOutputMark() != 0 { + binding.control = network.AutoRedirectOutputMarkFunc() + return binding, nil + } + if platformInterface != nil && platformInterface.UsePlatformNetworkInterfaces() { + if platformInterface.UsePlatformAutoDetectInterfaceControl() { + binding.control = func(_, _ string, conn syscall.RawConn) error { + return control.Raw(conn, func(fileDescriptor uintptr) error { + return platformInterface.AutoDetectInterfaceControl(int(fileDescriptor)) + }) + } + return binding, nil + } + // NEPacketTunnelProvider sockets are excluded from tunnel routes by + // NECP; the empty override only suppresses tailscale's own + // default-interface bind, which would select the sing-box utun. + binding.control = func(string, string, syscall.RawConn) error { + return nil + } + return binding, nil + } + bindFunc := network.AutoDetectInterfaceFunc() + if bindFunc == nil { + return binding, nil + } + binding.control = bindFunc + binding.listenPacket = func(listenContext context.Context, networkName string, address string) (nettype.PacketConn, error) { + listenConfig := net.ListenConfig{ + Control: control.Append(bindFunc, control.DisableUDPNetReset()), + } + packetConn, err := listenConfig.ListenPacket(listenContext, networkName, address) + if err != nil { + return nil, err + } + udpConn := packetConn.(*net.UDPConn) + egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ + Logger: logger, + Network: networkName, + InterfaceFinder: network.InterfaceFinder(), + InterfaceMonitor: network.InterfaceMonitor(), + IsExempt: func() bool { + return network.AutoRedirectOutputMark() != 0 + }, + }) + if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) { + egressPool.Close() + return udpConn, nil + } + return tun.NewUDPEgressConn(udpConn, egressPool), nil + } + return binding, nil +} + +func (b systemBinding) hooks(dialer N.Dialer) netmon.Hooks { + return netmon.Hooks{Dialer: dialer, Control: b.control, ListenPacket: b.listenPacket, DefaultRoute: b.defaultRoute} +} + +func (b systemBinding) watch(netMon *netmon.Monitor) (unwatch func()) { + if b.interfaceFinder == nil { + return func() {} + } + var access sync.Mutex + lastInterfaces := b.interfaceFinder.Interfaces() + element := b.interfaceFinder.RegisterInterfaceUpdateCallback(func(interfaces []control.Interface) { + access.Lock() + changed := !slices.EqualFunc(lastInterfaces, interfaces, control.Interface.Equals) + lastInterfaces = interfaces + access.Unlock() + if changed { + netMon.Poll() + } + }) + return func() { + b.interfaceFinder.UnregisterInterfaceUpdateCallback(element) + } +} diff --git a/protocol/tailscale/tailcat_derp.go b/protocol/tailscale/tailcat_derp.go new file mode 100644 index 000000000..484004a2e --- /dev/null +++ b/protocol/tailscale/tailcat_derp.go @@ -0,0 +1,171 @@ +//go:build with_tailscale + +package tailscale + +import ( + "context" + "io" + "net/http" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" + "github.com/sagernet/tailscale/tailcfg" +) + +const ( + tailcatDefaultDERPMapURL = "https://tailcat.dev/derpmap.json" + tailcatDERPMapMaxAge = time.Hour + tailcatDERPMapMaxSize = 8 << 20 + tailcatDERPMapTimeout = 10 * time.Second +) + +type tailcatDERP struct { + region *tailcfg.DERPRegion + regionID int + mapURL string + mode string + httpClient option.HTTPClientOptions + transport adapter.HTTPTransport + access sync.Mutex + cachedMap []byte + cachedETag string + cachedSince time.Time +} + +func newTailcatDERP(options option.TailcatDERPOptions, mode string) (*tailcatDERP, error) { + derp := &tailcatDERP{mode: mode} + switch { + case len(options.DERPServers) > 0 && options.DERPRegion != 0: + return nil, E.New("`derp_region` conflicts with `derp_servers`") + case len(options.DERPServers) > 0: + if options.DERPMapURL != "" { + return nil, E.New("`derp_map_url` conflicts with `derp_servers`") + } + if options.HTTPClient != nil { + return nil, E.New("`http_client` conflicts with `derp_servers`") + } + region := &tailcfg.DERPRegion{RegionID: 1, RegionCode: "1"} + for _, server := range options.DERPServers { + if server.Host == "" { + return nil, E.New("missing `host` in `derp_servers`") + } + region.Nodes = append(region.Nodes, &tailcfg.DERPNode{ + Name: server.Host, + RegionID: 1, + HostName: server.Host, + CertName: server.CertName, + IPv4: server.IPv4, + IPv6: server.IPv6, + STUNPort: int(server.STUNPort), + DERPPort: int(server.DERPPort), + }) + } + derp.region = region + case options.DERPRegion > 0: + derp.regionID = options.DERPRegion + derp.mapURL = options.DERPMapURL + if derp.mapURL == "" { + derp.mapURL = tailcatDefaultDERPMapURL + } + derp.httpClient = common.PtrValueOrDefault(options.HTTPClient) + default: + return nil, E.New("one of `derp_region` or `derp_servers` is required") + } + return derp, nil +} + +func (d *tailcatDERP) start(ctx context.Context, logger logger.ContextLogger) error { + if d.region != nil { + return nil + } + transport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, d.httpClient) + if err != nil { + return E.Cause(err, "create DERP map http client") + } + d.transport = transport + return nil +} + +func (d *tailcatDERP) resolve(ctx context.Context) (*tailcfg.DERPRegion, error) { + if d.region != nil { + return d.region, nil + } + derpMap, err := d.fetchMap(ctx) + if err != nil { + return nil, E.Cause(err, "fetch DERP map") + } + region, loaded := derpMap.Regions[d.regionID] + if !loaded { + return nil, E.New("DERP region ", d.regionID, " not found in ", d.mapURL) + } + return region, nil +} + +// Mirrors tailcat's cache policy: a copy younger than an hour is used +// as is, an older one is revalidated with If-None-Match, and any copy +// serves as a fallback when the fetch fails. The Tailcat-Mode header +// lets tailcat.dev filter its map for servers. +func (d *tailcatDERP) fetchMap(ctx context.Context) (*tailcfg.DERPMap, error) { + d.access.Lock() + defer d.access.Unlock() + if d.cachedMap != nil && time.Since(d.cachedSince) < tailcatDERPMapMaxAge { + return decodeTailcatDERPMap(d.cachedMap) + } + ctx, cancel := context.WithTimeout(ctx, tailcatDERPMapTimeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, d.mapURL, nil) + if err != nil { + return nil, err + } + request.Header.Set("Tailcat-Mode", d.mode) + if d.cachedETag != "" { + request.Header.Set("If-None-Match", d.cachedETag) + } + response, err := (&http.Client{Transport: d.transport}).Do(request) + if err != nil { + return d.staleOr(err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotModified && d.cachedMap != nil { + d.cachedSince = time.Now() + return decodeTailcatDERPMap(d.cachedMap) + } + if response.StatusCode != http.StatusOK { + return d.staleOr(E.New("unexpected status: ", response.Status)) + } + content, err := io.ReadAll(io.LimitReader(response.Body, tailcatDERPMapMaxSize)) + if err != nil { + return d.staleOr(err) + } + derpMap, err := decodeTailcatDERPMap(content) + if err != nil { + return d.staleOr(err) + } + d.cachedMap = content + d.cachedETag = response.Header.Get("Etag") + d.cachedSince = time.Now() + return derpMap, nil +} + +func (d *tailcatDERP) staleOr(err error) (*tailcfg.DERPMap, error) { + if d.cachedMap == nil { + return nil, err + } + return decodeTailcatDERPMap(d.cachedMap) +} + +func decodeTailcatDERPMap(content []byte) (*tailcfg.DERPMap, error) { + var derpMap tailcfg.DERPMap + err := json.Unmarshal(content, &derpMap) + if err != nil { + return nil, E.Cause(err, "decode DERP map") + } + return &derpMap, nil +} diff --git a/protocol/tailscale/tailcat_disco.go b/protocol/tailscale/tailcat_disco.go new file mode 100644 index 000000000..fc5917da6 --- /dev/null +++ b/protocol/tailscale/tailcat_disco.go @@ -0,0 +1,101 @@ +//go:build with_tailscale + +package tailscale + +import ( + "fmt" + "net/netip" + + "github.com/sagernet/tailscale/types/key" + + "go4.org/mem" +) + +// Meow messages are raw DERP packets, not disco frames: 4-byte magic, +// 1-byte type, then the payload. +var tailcatMeowMagic = [4]byte{'m', 'e', 'o', 'w'} + +const ( + tailcatMeowTypePing = 0x01 + tailcatMeowTypePong = 0x02 +) + +func isTailcatMeowPacket(packet []byte) bool { + return len(packet) >= 4 && [4]byte(packet[:4]) == tailcatMeowMagic +} + +func isTailcatMeowedPacket(packet []byte) bool { + return len(packet) >= 5 && [4]byte(packet[:4]) == tailcatMeowMagic && packet[4] == tailcatMeowTypePong +} + +func encodeTailcatMeowPing(nodeKey key.NodePublic, discoKey key.DiscoPublic) []byte { + packet := make([]byte, 0, 4+1+key.NodePublicRawLen+key.DiscoPublicRawLen) + packet = append(packet, tailcatMeowMagic[:]...) + packet = append(packet, tailcatMeowTypePing) + packet = nodeKey.AppendTo(packet) + packet = discoKey.AppendTo(packet) + return packet +} + +func encodeTailcatMeowed(sessionID [16]byte) []byte { + packet := make([]byte, 0, 5+len(sessionID)) + packet = append(packet, tailcatMeowMagic[:]...) + packet = append(packet, tailcatMeowTypePong) + return append(packet, sessionID[:]...) +} + +func parseTailcatMeowPing(packet []byte) (key.NodePublic, key.DiscoPublic, bool) { + if len(packet) < 4+1+key.NodePublicRawLen+key.DiscoPublicRawLen || packet[4] != tailcatMeowTypePing { + return key.NodePublic{}, key.DiscoPublic{}, false + } + discoKey := key.DiscoPublicFromRaw32(mem.B(packet[5+key.NodePublicRawLen : 5+key.NodePublicRawLen+key.DiscoPublicRawLen])) + if discoKey.IsZero() { + return key.NodePublic{}, key.DiscoPublic{}, false + } + return key.NodePublicFromRaw32(mem.B(packet[5 : 5+key.NodePublicRawLen])), discoKey, true +} + +func tailcatDiscoPrivateForNode(nodeKey key.NodePrivate) key.DiscoPrivate { + discoKey := TailcatDiscoPrivateKey(nodeKey.Raw32()) + return key.DiscoPrivateFromRaw32(mem.B(discoKey[:])) +} + +func tailcatAddressForKey(nodeKey key.NodePublic) netip.Addr { + return TailcatAddress([32]byte(nodeKey.AppendTo(nil))) +} + +// The text form is the supported way to build a node key; parsing it +// back cannot fail for a 32-byte input. +func tailcatNodePrivate(privateKey [32]byte) key.NodePrivate { + var nodeKey key.NodePrivate + err := nodeKey.UnmarshalText(fmt.Appendf(nil, "privkey:%x", privateKey[:])) + if err != nil { + panic(err) + } + return nodeKey +} + +func tailcatPrefixForKey(nodeKey key.NodePublic) netip.Prefix { + address := tailcatAddressForKey(nodeKey) + return netip.PrefixFrom(address, address.BitLen()) +} + +var tailcatNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96") + +func tailcatMapNAT64(address netip.Addr) netip.Addr { + if !address.Is4() { + return address + } + mapped := tailcatNAT64Prefix.Addr().As16() + address4 := address.As4() + copy(mapped[12:], address4[:]) + return netip.AddrFrom16(mapped) +} + +func tailcatUnmapNAT64(address netip.Addr) netip.Addr { + if !tailcatNAT64Prefix.Contains(address) { + return address + } + mapped := address.As16() + return netip.AddrFrom4([4]byte(mapped[12:])) +} diff --git a/protocol/tailscale/tailcat_inbound.go b/protocol/tailscale/tailcat_inbound.go new file mode 100644 index 000000000..d272ca109 --- /dev/null +++ b/protocol/tailscale/tailcat_inbound.go @@ -0,0 +1,248 @@ +//go:build with_tailscale + +package tailscale + +import ( + "context" + "maps" + "net" + "net/netip" + "slices" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/wireguard-go/device" + + "go4.org/mem" +) + +var ( + _ adapter.Inbound = (*TailcatInbound)(nil) + _ adapter.InterfaceUpdateListener = (*TailcatInbound)(nil) + _ adapter.Referrer = (*TailcatInbound)(nil) + _ tun.Handler = (*TailcatInbound)(nil) +) + +func RegisterTailcatInbound(registry *inbound.Registry) { + inbound.Register[option.TailcatInboundOptions](registry, C.TypeTailcat, NewTailcatInbound) +} + +type TailcatInbound struct { + inbound.Adapter + ctx context.Context + router adapter.Router + logger log.ContextLogger + dnsRouter adapter.DNSRouter + dialer N.Dialer + queryOptions adapter.DNSQueryOptions + detour string + privateKey key.NodePrivate + presharedKey device.NoisePresharedKey + users map[key.NodePublic]string + derp *tailcatDERP + node *tailcatNode +} + +func NewTailcatInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailcatInboundOptions) (adapter.Inbound, error) { + privateKey, err := DecodeTailcatKey(options.PrivateKey) + if err != nil { + return nil, E.Cause(err, "parse private_key") + } + var presharedKey device.NoisePresharedKey + if options.PreSharedKey != "" { + presharedKey, err = DecodeTailcatKey(options.PreSharedKey) + if err != nil { + return nil, E.Cause(err, "parse pre_shared_key") + } + } + var users map[key.NodePublic]string + if len(options.Users) > 0 { + users = make(map[key.NodePublic]string, len(options.Users)) + for index, user := range options.Users { + publicKey, err := DecodeTailcatKey(user.PublicKey) + if err != nil { + return nil, E.Cause(err, "parse users[", index, "].public_key") + } + users[key.NodePublicFromRaw32(mem.B(publicKey[:]))] = user.Name + } + } + derp, err := newTailcatDERP(options.TailcatDERPOptions, "server") + if err != nil { + return nil, err + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: true, + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + return &TailcatInbound{ + Adapter: inbound.NewAdapter(C.TypeTailcat, tag), + ctx: ctx, + router: router, + logger: logger, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + dialer: outboundDialer, + queryOptions: outboundDialer.(dialer.ResolveDialer).QueryOptions(), + detour: options.Detour, + privateKey: tailcatNodePrivate(privateKey), + presharedKey: presharedKey, + users: users, + derp: derp, + }, nil +} + +func (i *TailcatInbound) PublicKey() key.NodePublic { + return i.privateKey.Public() +} + +func (i *TailcatInbound) UserPublicKeys() []key.NodePublic { + return slices.Collect(maps.Keys(i.users)) +} + +func (i *TailcatInbound) References() []string { + if i.detour == "" { + return nil + } + return []string{i.detour} +} + +func (i *TailcatInbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStatePostStart { + return nil + } + err := i.derp.start(i.ctx, i.logger) + if err != nil { + return err + } + region, err := i.derp.resolve(i.ctx) + if err != nil { + return err + } + binding, err := newSystemBinding(i.ctx, i.logger) + if err != nil { + return err + } + node, err := newTailcatNode(tailcatNodeOptions{ + Context: scope.Context(), + Logger: i.logger, + PrivateKey: i.privateKey, + PresharedKey: i.presharedKey, + Region: region, + Dialer: i.dialer, + Binding: binding, + LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) { + return i.dnsRouter.Lookup(ctx, host, i.queryOptions) + }, + MemoryPressure: oomkiller.MemoryPressure(i.ctx), + Handler: i, + Users: i.users, + }) + if err != nil { + return err + } + scope.Add(func() error { + node.close() + return nil + }) + err = node.start() + if err != nil { + return err + } + i.node = node + return nil +} + +func (i *TailcatInbound) InterfaceUpdated(ctx context.Context) { + if i.node != nil { + i.node.interfaceUpdated() + } +} + +// Connections to the server's own address are served locally, like the +// Tailscale endpoint does; everything else is exit-node traffic, whose +// IPv4 destinations arrive NAT64-mapped. +func (i *TailcatInbound) rewriteDestination(destination netip.Addr) netip.Addr { + if destination == i.node.address { + return netip.IPv6Loopback() + } + return tailcatUnmapNAT64(destination) +} + +func (i *TailcatInbound) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + if i.rewriteDestination(destination.Addr()) != destination.Addr() { + return tun.FlowVerdict{Action: tun.ActionAccept} + } + return adapter.JudgeFlow(i.router, adapter.InboundContext{ + Inbound: i.Tag(), + InboundType: i.Type(), + User: i.node.userName(source.Addr()), + }, network, source, destination, firstPacket) +} + +func (i *TailcatInbound) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + ctx := log.ContextWithNewID(i.ctx) + var metadata adapter.InboundContext + metadata.Inbound = i.Tag() + metadata.InboundType = i.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.User = i.node.userName(source.Addr) + metadata.Protocol = C.ProtocolDNS + i.logger.InfoContext(ctx, "inbound DNS packet from ", source) + i.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + +func (i *TailcatInbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = i.Tag() + metadata.InboundType = i.Type() + metadata.Source = source + metadata.User = i.node.userName(source.Addr) + rewritten := i.rewriteDestination(destination.Addr) + if rewritten != destination.Addr { + metadata.OriginDestination = destination + destination.Addr = rewritten + } + metadata.Destination = destination + i.logger.InfoContext(ctx, "inbound connection from ", source) + i.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + i.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (i *TailcatInbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + var metadata adapter.InboundContext + metadata.Inbound = i.Tag() + metadata.InboundType = i.Type() + metadata.Source = source + metadata.User = i.node.userName(source.Addr) + rewritten := i.rewriteDestination(destination.Addr) + if rewritten != destination.Addr { + metadata.OriginDestination = destination + destination.Addr = rewritten + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + i.logger.InfoContext(ctx, "inbound packet connection from ", source) + i.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + i.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/tailscale/tailcat_key.go b/protocol/tailscale/tailcat_key.go new file mode 100644 index 000000000..7b0386a0f --- /dev/null +++ b/protocol/tailscale/tailcat_key.go @@ -0,0 +1,74 @@ +package tailscale + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "net/netip" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/crypto/curve25519" +) + +// tailcat derives the path-discovery key from the node key instead of +// generating one; both peers must agree on this derivation for the +// server's disco public key in a client config to match what the +// server signs disco frames with. +const tailcatDiscoKeyLabel = "github.com/tailscale/tailcat disco key v1" + +func NewTailcatPrivateKey() [32]byte { + var privateKey [32]byte + _, err := rand.Read(privateKey[:]) + if err != nil { + panic(err) + } + clampTailcatPrivateKey(&privateKey) + return privateKey +} + +func TailcatPublicKey(privateKey [32]byte) [32]byte { + publicKey, err := curve25519.X25519(privateKey[:], curve25519.Basepoint) + if err != nil { + panic(err) + } + return [32]byte(publicKey) +} + +func TailcatDiscoPrivateKey(privateKey [32]byte) [32]byte { + mac := hmac.New(sha256.New, privateKey[:]) + mac.Write([]byte(tailcatDiscoKeyLabel)) + discoKey := [32]byte(mac.Sum(nil)) + clampTailcatPrivateKey(&discoKey) + return discoKey +} + +func DecodeTailcatKey(value string) ([32]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return [32]byte{}, E.Cause(err, "decode base64") + } + if len(decoded) != 32 { + return [32]byte{}, E.New("unexpected key length: ", len(decoded)) + } + return [32]byte(decoded), nil +} + +func EncodeTailcatKey(key [32]byte) string { + return base64.StdEncoding.EncodeToString(key[:]) +} + +// tailcat addresses live in Tailscale's ULA range with the top 80 bits +// of the node public key as the interface identifier. +func TailcatAddress(publicKey [32]byte) netip.Addr { + var address [16]byte + copy(address[:], []byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0}) + copy(address[6:], publicKey[:10]) + return netip.AddrFrom16(address) +} + +func clampTailcatPrivateKey(key *[32]byte) { + key[0] &= 248 + key[31] = (key[31] & 127) | 64 +} diff --git a/protocol/tailscale/tailcat_node.go b/protocol/tailscale/tailcat_node.go new file mode 100644 index 000000000..8cbf29289 --- /dev/null +++ b/protocol/tailscale/tailcat_node.go @@ -0,0 +1,639 @@ +//go:build with_tailscale + +package tailscale + +import ( + "cmp" + "context" + "crypto/rand" + "fmt" + "net" + "net/netip" + "slices" + "sync" + "time" + + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/tailscale/disco" + "github.com/sagernet/tailscale/health" + tsDNS "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tsd" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/key" + tsLogger "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/eventbus" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/netstack" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/wireguard-go/device" + + "go4.org/netipx" +) + +var ( + tailcatAllIPv6 = netip.MustParsePrefix("::/0") + tailcatULASet = sync.OnceValue(func() *netipx.IPSet { + var builder netipx.IPSetBuilder + builder.AddPrefix(tsaddr.TailscaleULARange()) + set, err := builder.IPSet() + if err != nil { + panic(err) + } + return set + }) +) + +const ( + tailcatMeowTimeout = 10 * time.Second + tailcatMeowInterval = time.Second + tailcatMeowRefreshInterval = 30 * time.Second +) + +type tailcatNodeOptions struct { + Context context.Context + Logger logger.ContextLogger + PrivateKey key.NodePrivate + PresharedKey device.NoisePresharedKey + Region *tailcfg.DERPRegion + Dialer N.Dialer + Binding systemBinding + LookupHook dnscache.LookupHookFunc + MemoryPressure func() tun.MemoryPressure + + // Server only + Handler tun.Handler + Users map[key.NodePublic]string + + // Client only + ServerPublicKey key.NodePublic + ServerDiscoKey key.DiscoPublic +} + +type tailcatNode struct { + ctx context.Context + cancel context.CancelFunc + logger logger.ContextLogger + logf tsLogger.Logf + sys tsd.System + privateKey key.NodePrivate + publicKey key.NodePublic + discoPrivate key.DiscoPrivate + presharedKey device.NoisePresharedKey + address netip.Addr + prefix netip.Prefix + region *tailcfg.DERPRegion + netstack *netstack.Impl + isServer bool + + users map[key.NodePublic]string + serverPublicKey key.NodePublic + serverDiscoKey key.DiscoPublic + sessionID [16]byte + meowed chan struct{} + meowResponses chan [16]byte + meowRequests chan struct{} + meowWait sync.WaitGroup + + access sync.Mutex + clients map[key.NodePublic]*tailcfg.Node + clientsByAddress map[netip.Addr]key.NodePublic + networkMap *netmap.NetworkMap + endpoints []netip.AddrPort + unwatch func() + closeOnce sync.Once +} + +func newTailcatNode(options tailcatNodeOptions) (*tailcatNode, error) { + publicKey := options.PrivateKey.Public() + address := tailcatAddressForKey(publicKey) + isServer := options.ServerPublicKey.IsZero() + // Clients announce their disco key in the meow ping, so a fresh one + // per tunnel makes a reconnecting client look new to the server, + // which otherwise keeps trusting the previous direct path until it + // times out. The server's must be derived: it is the key clients + // are configured with. + discoPrivate := key.NewDisco() + if isServer { + discoPrivate = tailcatDiscoPrivateForNode(options.PrivateKey) + } + ctx, cancel := context.WithCancel(options.Context) + node := &tailcatNode{ + ctx: ctx, + cancel: cancel, + logger: options.Logger, + privateKey: options.PrivateKey, + publicKey: publicKey, + discoPrivate: discoPrivate, + presharedKey: options.PresharedKey, + address: address, + prefix: netip.PrefixFrom(address, address.BitLen()), + region: options.Region, + isServer: isServer, + users: options.Users, + serverPublicKey: options.ServerPublicKey, + serverDiscoKey: options.ServerDiscoKey, + meowed: make(chan struct{}), + meowResponses: make(chan [16]byte, 1), + meowRequests: make(chan struct{}, 1), + clientsByAddress: make(map[netip.Addr]key.NodePublic), + } + if isServer { + rand.Read(node.sessionID[:]) + } + node.logf = func(format string, args ...any) { + node.logger.Trace(fmt.Sprintf(format, args...)) + } + sys := &node.sys + bus := eventbus.New() + sys.Set(bus) + sys.Set(health.NewTracker(bus)) + netMon, err := netmon.New(bus, node.logf, options.Binding.hooks(options.Dialer)) + if err != nil { + node.close() + return nil, E.Cause(err, "create network monitor") + } + sys.Set(netMon) + node.unwatch = options.Binding.watch(netMon) + dialer := &tsdial.Dialer{Logf: node.logf, Dialer: options.Dialer} + sys.Set(dialer) + appName := "tailcat-client" + if node.isServer { + appName = "tailcat-server" + } + engine, err := wgengine.NewUserspaceEngine(node.logf, wgengine.Config{ + Context: ctx, + NetMon: netMon, + Dialer: dialer, + SetSubsystem: sys.Set, + Metrics: sys.UserMetricsRegistry(), + HealthTracker: sys.HealthTracker.Get(), + EventBus: bus, + OnDERPRecv: node.onDERPRecv, + DERPAppName: appName, + ForceDiscoKey: node.discoPrivate, + LookupHook: options.LookupHook, + }) + if err != nil { + node.close() + return nil, E.Cause(err, "create WireGuard engine") + } + sys.Set(engine) + sys.NetstackRouter.Set(true) + netStack, err := netstack.Create(node.logf, sys.Tun.Get(), sys.DNSManager.Get(), sys.ProxyMapper(), options.MemoryPressure) + if err != nil { + node.close() + return nil, E.Cause(err, "create netstack") + } + netStack.ProcessLocalIPs = true + if node.isServer { + netStack.ProcessSubnets = true + netStack.Handler = options.Handler + } else { + netStack.GetTCPHandlerForFlow = func(source, destination netip.AddrPort) (func(net.Conn), bool) { + return nil, true + } + netStack.GetUDPHandlerForFlow = func(source, destination netip.AddrPort) (func(nettype.ConnPacketConn), bool) { + return nil, true + } + } + node.netstack = netStack + sys.Set(netStack) + sys.Tun.Get().Start() + engine.SetFilter(node.buildFilter()) + return node, nil +} + +func (n *tailcatNode) buildFilter() *filter.Filter { + var localNets netipx.IPSetBuilder + localNets.AddPrefix(n.prefix) + var matches []filter.Match + if n.isServer { + localNets.AddPrefix(tailcatAllIPv6) + matches = []filter.Match{{ + IPProto: views.SliceOf([]ipproto.Proto{ipproto.TCP, ipproto.UDP}), + Srcs: []netip.Prefix{tailcatAllIPv6}, + Dsts: []filter.NetPortRange{{Net: tailcatAllIPv6, Ports: filter.PortRange{First: 0, Last: 65535}}}, + }} + } + local, err := localNets.IPSet() + if err != nil { + panic(err) + } + return filter.New(matches, nil, local, tailcatULASet(), nil, n.logf) +} + +func (n *tailcatNode) start() error { + err := n.netstack.Start(nil) + if err != nil { + return E.Cause(err, "start netstack") + } + engine := n.sys.Engine.Get() + magicConn := n.sys.MagicSock.Get() + err = magicConn.SetPrivateKey(n.privateKey) + if err != nil { + return E.Cause(err, "set private key") + } + magicConn.SetDERPMap(&tailcfg.DERPMap{Regions: map[int]*tailcfg.DERPRegion{n.region.RegionID: n.region}}) + networkMap := n.buildNetworkMap(nil) + n.access.Lock() + n.networkMap = networkMap + n.access.Unlock() + magicConn.SetNetworkMap(networkMap.SelfNode, networkMap.Peers) + engine.SetSelfNode(networkMap.SelfNode) + n.netstack.UpdateNetstackIPs(networkMap) + magicConn.SetNetworkUp(true) + engine.SetPeerConfigFunc(n.peerConfig) + engine.SetPeerByIPPacketFunc(n.peerByIP) + engine.SetPeerForIPFunc(n.peerForIP) + engine.SetStatusCallback(n.onEngineStatus) + wgConfig := &wgcfg.Config{ + PrivateKey: n.privateKey, + Addresses: []netip.Prefix{n.prefix}, + } + if n.isServer { + wgConfig.Addresses = append(wgConfig.Addresses, tailcatAllIPv6) + } + err = engine.Reconfig(wgConfig, &router.Config{LocalAddrs: []netip.Prefix{n.prefix}}, &tsDNS.Config{}) + if err != nil { + return E.Cause(err, "configure WireGuard engine") + } + n.sys.NetMon.Get().Start() + if !n.isServer { + n.meowWait.Go(n.loopMeow) + } + return nil +} + +func (n *tailcatNode) close() { + n.closeOnce.Do(func() { + n.cancel() + n.meowWait.Wait() + if n.netstack != nil { + n.netstack.Close() + } + if engine, loaded := n.sys.Engine.GetOK(); loaded { + engine.Close() + } + if n.unwatch != nil { + n.unwatch() + } + if netMon, loaded := n.sys.NetMon.GetOK(); loaded { + netMon.Close() + } + if dialer, loaded := n.sys.Dialer.GetOK(); loaded { + dialer.Close() + } + if bus, loaded := n.sys.Bus.GetOK(); loaded { + bus.Close() + } + }) +} + +func (n *tailcatNode) selfNode() *tailcfg.Node { + node := &tailcfg.Node{ + ID: 1, + StableID: "1", + Name: "server.tailcat.", + User: 100, + Key: n.publicKey, + DiscoKey: n.discoPrivate.Public(), + Addresses: []netip.Prefix{n.prefix}, + HomeDERP: n.region.RegionID, + } + if n.isServer { + node.AllowedIPs = []netip.Prefix{n.prefix, tailcatAllIPv6} + } else { + node.ID = 2 + node.StableID = "2" + node.Name = "client.tailcat." + } + return node +} + +// buildNetworkMap must be called with n.access held when clients is +// the live map; start passes nil before any client exists. +func (n *tailcatNode) buildNetworkMap(clients map[key.NodePublic]*tailcfg.Node) *netmap.NetworkMap { + networkMap := &netmap.NetworkMap{ + NodeKey: n.publicKey, + SelfNode: n.selfNode().View(), + } + if n.isServer { + for _, client := range clients { + networkMap.Peers = append(networkMap.Peers, client.View()) + } + slices.SortFunc(networkMap.Peers, func(a, b tailcfg.NodeView) int { + return cmp.Compare(a.ID(), b.ID()) + }) + } else { + networkMap.Peers = []tailcfg.NodeView{(&tailcfg.Node{ + ID: 1, + StableID: "1", + Name: "server.tailcat.", + User: 100, + Key: n.serverPublicKey, + DiscoKey: n.serverDiscoKey, + Addresses: []netip.Prefix{tailcatPrefixForKey(n.serverPublicKey)}, + AllowedIPs: []netip.Prefix{tailcatPrefixForKey(n.serverPublicKey), tailcatAllIPv6}, + HomeDERP: n.region.RegionID, + }).View()} + } + return networkMap +} + +func (n *tailcatNode) peerConfig(nodeKey key.NodePublic) (wgcfg.PeerConfig, bool) { + if !n.isServer { + if nodeKey != n.serverPublicKey { + return wgcfg.PeerConfig{}, false + } + return wgcfg.PeerConfig{ + AllowedIPs: []netip.Prefix{tailcatPrefixForKey(nodeKey), tailcatAllIPv6}, + PresharedKey: n.presharedKey, + }, true + } + n.access.Lock() + defer n.access.Unlock() + client, loaded := n.clients[nodeKey] + if !loaded { + return wgcfg.PeerConfig{}, false + } + return wgcfg.PeerConfig{AllowedIPs: client.AllowedIPs, PresharedKey: n.presharedKey}, true +} + +func (n *tailcatNode) peerByIP(destination netip.Addr) (key.NodePublic, bool) { + if !n.isServer { + return n.serverPublicKey, true + } + n.access.Lock() + defer n.access.Unlock() + nodeKey, loaded := n.clientsByAddress[destination] + return nodeKey, loaded +} + +func (n *tailcatNode) peerForIP(address netip.Addr) (wgengine.PeerForIP, bool) { + n.access.Lock() + defer n.access.Unlock() + if n.networkMap == nil { + return wgengine.PeerForIP{}, false + } + if tailcatNodeHasAddress(n.networkMap.SelfNode, address) { + return wgengine.PeerForIP{Node: n.networkMap.SelfNode, IsSelf: true}, true + } + for _, peer := range n.networkMap.Peers { + if tailcatNodeHasAddress(peer, address) { + return wgengine.PeerForIP{Node: peer}, true + } + } + return wgengine.PeerForIP{}, false +} + +func tailcatNodeHasAddress(node tailcfg.NodeView, address netip.Addr) bool { + if !node.Valid() { + return false + } + for _, prefix := range node.Addresses().All() { + if prefix.Addr() == address { + return true + } + } + return false +} + +func (n *tailcatNode) userName(address netip.Addr) string { + n.access.Lock() + defer n.access.Unlock() + nodeKey, loaded := n.clientsByAddress[address] + if !loaded { + return "" + } + return n.users[nodeKey] +} + +func (n *tailcatNode) onDERPRecv(regionID int, source key.NodePublic, packet []byte) bool { + if !isTailcatMeowPacket(packet) { + return false + } + if isTailcatMeowedPacket(packet) { + if !n.isServer && source == n.serverPublicKey { + var sessionID [16]byte + if len(packet) >= 5+len(sessionID) { + copy(sessionID[:], packet[5:5+len(sessionID)]) + } + select { + case n.meowResponses <- sessionID: + default: + } + } + return true + } + if !n.isServer { + return true + } + nodeKey, discoKey, valid := parseTailcatMeowPing(packet) + if !valid { + return false + } + if nodeKey.IsZero() || nodeKey != source { + return true + } + go func() { + if !n.onMeow(nodeKey, discoKey) { + return + } + _, err := n.sys.MagicSock.Get().SendDERPPacketTo(source, regionID, encodeTailcatMeowed(n.sessionID)) + if err != nil { + n.logger.Debug(E.Cause(err, "reply meow from ", nodeKey.ShortString())) + } + }() + return true +} + +func (n *tailcatNode) onMeow(nodeKey key.NodePublic, discoKey key.DiscoPublic) bool { + n.access.Lock() + defer n.access.Unlock() + if n.users != nil { + if _, allowed := n.users[nodeKey]; !allowed { + n.logger.Debug("ignored meow from unknown client ", nodeKey.ShortString()) + return false + } + } + previous, loaded := n.clients[nodeKey] + if loaded && previous.DiscoKey == discoKey { + return true + } + id := tailcfg.NodeID(len(n.clients) + 2) + if loaded { + n.logger.Info("client ", nodeKey.ShortString(), " reconnected") + id = previous.ID + } else { + n.logger.Info("client ", nodeKey.ShortString(), " connected") + } + address := tailcatAddressForKey(nodeKey) + prefix := netip.PrefixFrom(address, address.BitLen()) + if n.clients == nil { + n.clients = make(map[key.NodePublic]*tailcfg.Node) + } + // A reconnecting client presents a new disco key, and its node is + // replaced rather than updated in place: magicsock skips a netmap + // update whose peer views all compare equal, and a view shares the + // node it wraps, so mutation would be invisible to it. + n.clients[nodeKey] = &tailcfg.Node{ + ID: id, + StableID: tailcfg.StableNodeID(fmt.Sprint(id)), + Name: fmt.Sprintf("client%d.tailcat.", id), + User: 100, + Key: nodeKey, + DiscoKey: discoKey, + Addresses: []netip.Prefix{prefix}, + AllowedIPs: []netip.Prefix{prefix}, + HomeDERP: n.region.RegionID, + } + n.clientsByAddress[address] = nodeKey + networkMap := n.buildNetworkMap(n.clients) + n.networkMap = networkMap + n.sys.MagicSock.Get().SetNetworkMap(networkMap.SelfNode, networkMap.Peers) + if loaded { + n.sys.Engine.Get().ResetDevicePeer(nodeKey) + } + n.netstack.UpdateNetstackIPs(networkMap) + go n.advertiseEndpoints() + return true +} + +func (n *tailcatNode) onEngineStatus(status *wgengine.Status, err error) { + if err != nil || status == nil { + return + } + endpoints := make([]netip.AddrPort, 0, len(status.LocalAddrs)) + for _, endpoint := range status.LocalAddrs { + endpoints = append(endpoints, endpoint.Addr) + } + slices.SortFunc(endpoints, func(a, b netip.AddrPort) int { return a.Compare(b) }) + endpoints = slices.Compact(endpoints) + n.access.Lock() + changed := !slices.Equal(endpoints, n.endpoints) + if changed { + n.endpoints = endpoints + } + n.access.Unlock() + if changed && len(endpoints) > 0 { + go n.advertiseEndpoints() + if !n.isServer { + n.requestMeow() + } + } +} + +// Without a control plane distributing endpoints, magicsock never +// tries a direct path to a peer; a call-me-maybe over DERP, sealed +// exactly as magicsock's own sendDiscoMessage does, is what makes the +// peer disco-ping our endpoints. +func (n *tailcatNode) advertiseEndpoints() { + n.access.Lock() + endpoints := slices.Clone(n.endpoints) + var peers []tailcfg.NodeView + if n.networkMap != nil { + peers = n.networkMap.Peers + } + n.access.Unlock() + if len(endpoints) == 0 || len(peers) == 0 { + return + } + payload := (&disco.CallMeMaybe{MyNumber: endpoints}).AppendMarshal(nil) + magicConn := n.sys.MagicSock.Get() + for _, peer := range peers { + packet := make([]byte, 0, 512) + packet = append(packet, disco.Magic...) + packet = n.discoPrivate.Public().AppendTo(packet) + packet = append(packet, n.discoPrivate.Shared(peer.DiscoKey()).Seal(payload)...) + _, err := magicConn.SendDERPPacketTo(peer.Key(), n.region.RegionID, packet) + if err != nil { + n.logger.Debug(E.Cause(err, "advertise endpoints to ", peer.Key().ShortString())) + } + } +} + +func (n *tailcatNode) meow(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, tailcatMeowTimeout) + defer cancel() + select { + case <-n.meowed: + return nil + case <-ctx.Done(): + return E.Cause(ctx.Err(), "wait for server") + case <-n.ctx.Done(): + return net.ErrClosed + } +} + +func (n *tailcatNode) requestMeow() { + select { + case n.meowRequests <- struct{}{}: + default: + } +} + +func (n *tailcatNode) loopMeow() { + magicConn := n.sys.MagicSock.Get() + packet := encodeTailcatMeowPing(n.publicKey, n.discoPrivate.Public()) + var registered bool + var serverSession [16]byte + var lastSent time.Time + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-n.ctx.Done(): + return + case sessionID := <-n.meowResponses: + restarted := registered && serverSession != sessionID + if restarted { + n.sys.Engine.Get().ResetDevicePeer(n.serverPublicKey) + n.logger.Info("server reconnected") + } + serverSession = sessionID + if !registered || restarted { + go n.advertiseEndpoints() + } + if !registered { + registered = true + close(n.meowed) + } + timer.Reset(tailcatMeowRefreshInterval) + case <-n.meowRequests: + timer.Reset(max(0, tailcatMeowInterval-time.Since(lastSent))) + case <-timer.C: + _, err := magicConn.SendDERPPacketTo(n.serverPublicKey, n.region.RegionID, packet) + if err != nil { + n.logger.Debug(E.Cause(err, "send meow")) + } + lastSent = time.Now() + timer.Reset(tailcatMeowInterval) + } + } +} + +func (n *tailcatNode) stack() *tun.Go { + return n.netstack.ExportIPStack() +} + +func (n *tailcatNode) interfaceUpdated() { + if netMon, loaded := n.sys.NetMon.GetOK(); loaded { + netMon.InjectEvent() + } + if !n.isServer { + n.requestMeow() + } +} diff --git a/protocol/tailscale/tailcat_outbound.go b/protocol/tailscale/tailcat_outbound.go new file mode 100644 index 000000000..2fd839fa2 --- /dev/null +++ b/protocol/tailscale/tailcat_outbound.go @@ -0,0 +1,438 @@ +//go:build with_tailscale + +package tailscale + +import ( + "context" + "net" + "net/netip" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/iponly" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + 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" + "github.com/sagernet/sing/service" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/wireguard-go/device" + + "go4.org/mem" +) + +var ( + _ adapter.Outbound = (*TailcatOutbound)(nil) + _ adapter.IdleConnectionKeeper = (*TailcatOutbound)(nil) + _ adapter.OutboundWithMultiplex = (*TailcatOutbound)(nil) + _ adapter.InterfaceUpdateListener = (*TailcatOutbound)(nil) + _ dialer.PacketDialerWithDestination = (*TailcatOutbound)(nil) +) + +func RegisterTailcatOutbound(registry *outbound.Registry) { + outbound.Register[option.TailcatOutboundOptions](registry, C.TypeTailcat, NewTailcatOutbound) +} + +type TailcatOutbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + dnsRouter adapter.DNSRouter + dialer N.Dialer + queryOptions adapter.DNSQueryOptions + privateKey key.NodePrivate + serverPublicKey key.NodePublic + serverDiscoKey key.DiscoPublic + presharedKey device.NoisePresharedKey + derp *tailcatDERP + access sync.Mutex + nodeContext context.Context + node *tailcatNode + active int + idle bool +} + +func NewTailcatOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailcatOutboundOptions) (adapter.Outbound, error) { + var privateKey [32]byte + if options.PrivateKey != "" { + var err error + privateKey, err = DecodeTailcatKey(options.PrivateKey) + if err != nil { + return nil, E.Cause(err, "parse private_key") + } + } else { + privateKey = NewTailcatPrivateKey() + } + serverPublicKey, err := DecodeTailcatKey(options.ServerPublicKey) + if err != nil { + return nil, E.Cause(err, "parse server_public_key") + } + serverDiscoKey, err := DecodeTailcatKey(options.ServerDiscoKey) + if err != nil { + return nil, E.Cause(err, "parse server_disco_key") + } + var presharedKey device.NoisePresharedKey + if options.PreSharedKey != "" { + presharedKey, err = DecodeTailcatKey(options.PreSharedKey) + if err != nil { + return nil, E.Cause(err, "parse pre_shared_key") + } + } + derp, err := newTailcatDERP(options.TailcatDERPOptions, "client") + if err != nil { + return nil, err + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: true, + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + return &TailcatOutbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTailcat, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + ctx: ctx, + logger: logger, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + dialer: outboundDialer, + queryOptions: outboundDialer.(dialer.ResolveDialer).QueryOptions(), + privateKey: tailcatNodePrivate(privateKey), + serverPublicKey: key.NodePublicFromRaw32(mem.B(serverPublicKey[:])), + serverDiscoKey: key.DiscoPublicFromRaw32(mem.B(serverDiscoKey[:])), + presharedKey: presharedKey, + derp: derp, + }, nil +} + +func (o *TailcatOutbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + o.access.Lock() + o.nodeContext = scope.Context() + o.access.Unlock() + scope.Add(func() error { + o.access.Lock() + o.closeNodeLocked() + o.access.Unlock() + return nil + }) + case adapter.StartStatePostStart: + return o.derp.start(o.ctx, o.logger) + } + return nil +} + +func (o *TailcatOutbound) closeNodeLocked() { + if o.node == nil { + return + } + o.logger.Info("disconnected from server") + o.node.close() + o.node = nil +} + +func (o *TailcatOutbound) acquire(ctx context.Context) (*tailcatNode, func(), error) { + o.access.Lock() + defer o.access.Unlock() + if o.nodeContext.Err() != nil { + return nil, nil, net.ErrClosed + } + node, err := o.ensureLocked(ctx) + if err != nil { + return nil, nil, err + } + o.active++ + keepSession := adapter.KeepSessionFromContext(ctx) + return node, sync.OnceFunc(func() { + o.access.Lock() + defer o.access.Unlock() + o.active-- + if o.active == 0 && o.idle && !keepSession { + o.closeNodeLocked() + } + }), nil +} + +func (o *TailcatOutbound) ensureLocked(ctx context.Context) (*tailcatNode, error) { + if o.node != nil { + o.node.requestMeow() + return o.node, nil + } + region, err := o.derp.resolve(ctx) + if err != nil { + return nil, err + } + binding, err := newSystemBinding(o.ctx, o.logger) + if err != nil { + return nil, err + } + node, err := newTailcatNode(tailcatNodeOptions{ + Context: o.nodeContext, + Logger: o.logger, + PrivateKey: o.privateKey, + PresharedKey: o.presharedKey, + Region: region, + Dialer: o.dialer, + Binding: binding, + LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) { + return o.dnsRouter.Lookup(ctx, host, o.queryOptions) + }, + MemoryPressure: oomkiller.MemoryPressure(o.ctx), + ServerPublicKey: o.serverPublicKey, + ServerDiscoKey: o.serverDiscoKey, + }) + if err != nil { + return nil, err + } + err = node.start() + if err != nil { + node.close() + return nil, err + } + err = node.meow(ctx) + if err != nil { + node.close() + return nil, E.Cause(err, "connect to server") + } + o.logger.Info("connected to server") + o.node = node + return node, nil +} + +func (o *TailcatOutbound) MultiplexEnabled() bool { + return true +} + +func (o *TailcatOutbound) SetKeepIdleConnections(keep bool) { + o.access.Lock() + defer o.access.Unlock() + o.idle = !keep + if o.idle && o.active == 0 { + o.closeNodeLocked() + } +} + +func (o *TailcatOutbound) CloseIdleConnections() { + o.access.Lock() + defer o.access.Unlock() + if o.active == 0 { + o.closeNodeLocked() + } +} + +func (o *TailcatOutbound) InterfaceUpdated(ctx context.Context) { + o.access.Lock() + defer o.access.Unlock() + if o.node != nil { + o.node.interfaceUpdated() + } +} + +func (o *TailcatOutbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + o.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + o.logger.InfoContext(ctx, "outbound packet connection to ", destination) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } + if destination.IsDomain() { + destinationAddresses, err := o.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, (*tailcatDialer)(o), network, destination, destinationAddresses) + } + return (*tailcatDialer)(o).DialContext(ctx, network, destination) +} + +func (o *TailcatOutbound) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + o.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if destination.IsDomain() { + destinationAddresses, err := o.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + packetConn, destinationAddress, err := N.ListenSerial(ctx, (*tailcatDialer)(o), destination, destinationAddresses) + if err != nil { + return nil, netip.Addr{}, err + } + return iponly.NewPacketConn(o.logger, packetConn), destinationAddress, nil + } + packetConn, err := (*tailcatDialer)(o).ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return iponly.NewPacketConn(o.logger, packetConn), destination.Addr, nil + } + return iponly.NewPacketConn(o.logger, packetConn), netip.Addr{}, nil +} + +func (o *TailcatOutbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := o.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +// tailcatDialer is the IP-only dialer behind the outbound: it brings +// the tunnel up, maps IPv4 destinations into the NAT64 prefix that the +// server unmaps, and tracks live connections for the idle teardown. +type tailcatDialer TailcatOutbound + +func (d *tailcatDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + node, release, err := (*TailcatOutbound)(d).acquire(ctx) + if err != nil { + return nil, err + } + mapped := netip.AddrPortFrom(tailcatMapNAT64(destination.Addr), destination.Port) + switch N.NetworkName(network) { + case N.NetworkTCP: + conn, err := node.stack().DialTCP(ctx, node.address, mapped) + if err != nil { + release() + return nil, err + } + return &tailcatConn{GoConn: conn, release: release}, nil + case N.NetworkUDP: + conn, err := node.stack().DialUDP(netip.AddrPortFrom(node.address, 0), mapped) + if err != nil { + release() + return nil, err + } + return &tailcatPacketConn{conn: conn, remote: destination.UDPAddr(), release: release}, nil + default: + release() + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (d *tailcatDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + node, release, err := (*TailcatOutbound)(d).acquire(ctx) + if err != nil { + return nil, err + } + conn, err := node.stack().ListenUDP(netip.AddrPortFrom(node.address, 0)) + if err != nil { + release() + return nil, err + } + return &tailcatPacketConn{conn: conn, release: release}, nil +} + +type tailcatConn struct { + *tun.GoConn + release func() +} + +func (c *tailcatConn) Close() error { + err := c.GoConn.Close() + c.release() + return err +} + +func (c *tailcatConn) Upstream() any { + return c.GoConn +} + +// GoUDPConn also offers buffer-based ReadPacket/WritePacket, which +// bufio prefers over ReadFrom/WriteTo, so every address-carrying +// method is wrapped rather than the conn being embedded. +type tailcatPacketConn struct { + conn *tun.GoUDPConn + remote net.Addr + release func() +} + +func (c *tailcatPacketConn) Read(buffer []byte) (int, error) { + return c.conn.Read(buffer) +} + +func (c *tailcatPacketConn) Write(buffer []byte) (int, error) { + return c.conn.Write(buffer) +} + +func (c *tailcatPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) { + n, source, err := c.conn.ReadFromUDPAddrPort(buffer) + if err != nil { + return n, nil, err + } + return n, net.UDPAddrFromAddrPort(netip.AddrPortFrom(tailcatUnmapNAT64(source.Addr()), source.Port())), nil +} + +func (c *tailcatPacketConn) WriteTo(buffer []byte, addr net.Addr) (int, error) { + destination := M.SocksaddrFromNet(addr) + if !destination.Addr.IsValid() { + return 0, E.New("invalid destination: ", addr) + } + return c.conn.WriteToUDPAddrPort(buffer, netip.AddrPortFrom(tailcatMapNAT64(destination.Addr), destination.Port)) +} + +func (c *tailcatPacketConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { + source, err := c.conn.ReadPacket(buffer) + if err != nil { + return M.Socksaddr{}, err + } + source.Addr = tailcatUnmapNAT64(source.Addr) + return source, nil +} + +func (c *tailcatPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + if !destination.Addr.IsValid() { + buffer.Release() + return E.New("invalid destination: ", destination) + } + destination.Addr = tailcatMapNAT64(destination.Addr) + return c.conn.WritePacket(buffer, destination) +} + +func (c *tailcatPacketConn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *tailcatPacketConn) RemoteAddr() net.Addr { + if c.remote != nil { + return c.remote + } + return c.conn.RemoteAddr() +} + +func (c *tailcatPacketConn) SetDeadline(deadline time.Time) error { + return c.conn.SetDeadline(deadline) +} + +func (c *tailcatPacketConn) SetReadDeadline(deadline time.Time) error { + return c.conn.SetReadDeadline(deadline) +} + +func (c *tailcatPacketConn) SetWriteDeadline(deadline time.Time) error { + return c.conn.SetWriteDeadline(deadline) +} + +func (c *tailcatPacketConn) Close() error { + err := c.conn.Close() + c.release() + return err +} diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 31797b96e..888d10efe 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -611,7 +611,7 @@ func (t *Inbound) JudgeFlow(network uint8, source netip.AddrPort, destination ne } return tun.FlowVerdict{Action: tun.ActionAccept} } - return adapter.JudgeFlow(t.router, t.tag, C.TypeTun, network, source, destination, firstPacket) + return adapter.JudgeFlow(t.router, adapter.InboundContext{Inbound: t.tag, InboundType: C.TypeTun}, network, source, destination, firstPacket) } func (t *Inbound) isDNSHijackDestination(destination M.Socksaddr) bool { diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index bc1d09f27..b31185f14 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -221,7 +221,7 @@ func (w *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n return tun.FlowVerdict{Action: tun.ActionAccept} } } - return adapter.JudgeFlow(w.router, w.Tag(), w.Type(), network, source, destination, firstPacket) + return adapter.JudgeFlow(w.router, adapter.InboundContext{Inbound: w.Tag(), InboundType: w.Type()}, network, source, destination, firstPacket) } func (w *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { diff --git a/service/derp/service.go b/service/derp/service.go index 5761a2bd3..7d4bd6fbc 100644 --- a/service/derp/service.go +++ b/service/derp/service.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "time" @@ -48,6 +49,7 @@ import ( "github.com/coder/websocket" "github.com/go-chi/render" + "go4.org/mem" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" //nolint:staticcheck ) @@ -67,6 +69,8 @@ type Service struct { configPath string verifyClientEndpoint []string verifyClientURL []*option.DERPVerifyClientURLOptions + verifyClientInbound []string + verifyClientKeys []key.NodePublic home string meshKey string meshKeyPath string @@ -96,6 +100,15 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio } } + verifyClientKeys := make([]key.NodePublic, 0, len(options.VerifyClientKey)) + for index, encodedKey := range options.VerifyClientKey { + publicKey, err := boxScale.DecodeTailcatKey(encodedKey) + if err != nil { + return nil, E.Cause(err, "parse verify_client_key[", index, "]") + } + verifyClientKeys = append(verifyClientKeys, key.NodePublicFromRaw32(mem.B(publicKey[:]))) + } + var stunListener *listener.Listener if options.STUN != nil && options.STUN.Enabled { if options.STUN.Listen == nil { @@ -127,6 +140,8 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio configPath: configPath, verifyClientEndpoint: options.VerifyClientEndpoint, verifyClientURL: options.VerifyClientURL, + verifyClientInbound: options.VerifyClientInbound, + verifyClientKeys: verifyClientKeys, home: options.Home, meshKey: options.MeshPSK, meshKeyPath: options.MeshPSKFile, @@ -162,6 +177,30 @@ func (d *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { server.SetVerifyClientURL(urls) } + verifyClientKeys := slices.Clone(d.verifyClientKeys) + if len(d.verifyClientInbound) > 0 { + inboundManager := service.FromContext[adapter.InboundManager](d.ctx) + for _, inboundTag := range d.verifyClientInbound { + inbound, loaded := inboundManager.Get(inboundTag) + if !loaded { + return E.New("verify_client_inbound: inbound not found: ", inboundTag) + } + tailcatInbound, isTailcat := inbound.(*boxScale.TailcatInbound) + if !isTailcat { + return E.New("verify_client_inbound: inbound is not Tailcat: ", inboundTag) + } + userKeys := tailcatInbound.UserPublicKeys() + if len(userKeys) == 0 { + return E.New("verify_client_inbound: inbound has no users: ", inboundTag) + } + verifyClientKeys = append(verifyClientKeys, tailcatInbound.PublicKey()) + verifyClientKeys = append(verifyClientKeys, userKeys...) + } + } + if len(verifyClientKeys) > 0 { + server.SetVerifyClientKeys(verifyClientKeys) + } + if d.meshKey != "" { server.SetMeshKey(d.meshKey) } else if d.meshKeyPath != "" { @@ -229,6 +268,7 @@ func (d *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if err != nil { return err } + scope.Add(d.stunListener.Close) go d.loopSTUNPacket(stunConn.(*net.UDPConn)) } case adapter.StartStatePostStart: diff --git a/test/go.mod b/test/go.mod index 10245160e..96d8c010a 100644 --- a/test/go.mod +++ b/test/go.mod @@ -18,9 +18,11 @@ require ( github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-tun v0.9.4-0.20260916041232-89d2e01a748a + github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5.0.20260916060041-a8fbeb4b0838 github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.12.0 go.uber.org/goleak v1.3.0 + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 golang.org/x/net v0.57.0 ) @@ -115,7 +117,7 @@ require ( github.com/pkg/sftp v1.13.10 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect - github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c // indirect + github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 // indirect github.com/sagernet/cors v1.2.1 // indirect github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 // indirect github.com/sagernet/cronet-go/all v0.0.0-20260912104727-0d28acc44093 // indirect @@ -162,8 +164,7 @@ require ( github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca // indirect github.com/sagernet/sing-vmess v0.2.8 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5.0.20260916041743-66c96c36515b // indirect - github.com/sagernet/wireguard-go v0.0.6 // indirect + github.com/sagernet/wireguard-go v0.0.8-0.20260917055437-bc6039037038 // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect @@ -191,7 +192,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect - go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect @@ -215,5 +215,3 @@ require ( ) replace github.com/sagernet/sing-tun => ../../sing-tun - -replace github.com/sagernet/tailscale => ../../tailscale diff --git a/test/go.sum b/test/go.sum index 96d448aab..19b58acbd 100644 --- a/test/go.sum +++ b/test/go.sum @@ -253,8 +253,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= -github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c h1:JdEDkgiwsDxiZb4+dQYgVOU/lND+jZxFmN5y/q49EXk= -github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= +github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 h1:KDXMGbzqLmDdKcld0T3wuMuqf1iezZq+DemgtZ/vhtQ= +github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= 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-20260912104727-0d28acc44093 h1:CsvsLVIli53L6nzgEpyRjjWnRRhTds4D3eh5sEnF5Sw= @@ -359,8 +359,10 @@ github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjW github.com/sagernet/sing-vmess v0.2.8/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/wireguard-go v0.0.6 h1:zAjWSzufYThDsyLHxPLyyR+XdpBMlFDWD7WxJAvynSw= -github.com/sagernet/wireguard-go v0.0.6/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= +github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5.0.20260916060041-a8fbeb4b0838 h1:YlcCeRWjFIp6dqxvfdH0i6ku17Lv3YzVuHY5qOA7Nv8= +github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5.0.20260916060041-a8fbeb4b0838/go.mod h1:xGiEZax5sZ6gHJcwQxp0GmJVtDHGUXPXTgr6Ksm+yIc= +github.com/sagernet/wireguard-go v0.0.8-0.20260917055437-bc6039037038 h1:1APVQQ0gdlzLXg1fcBVGx+k5xgCiQwB3rvsIKpUT+9A= +github.com/sagernet/wireguard-go v0.0.8-0.20260917055437-bc6039037038/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/test/tailcat_test.go b/test/tailcat_test.go new file mode 100644 index 000000000..a1de0b2dd --- /dev/null +++ b/test/tailcat_test.go @@ -0,0 +1,288 @@ +//go:build with_tailscale + +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "io" + "net" + "net/netip" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/tailscale" + "github.com/sagernet/sing/common" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/tailscale/derp" + "github.com/sagernet/tailscale/derp/derphttp" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/key" + + "github.com/stretchr/testify/require" + "go4.org/mem" +) + +func TestTailcat(t *testing.T) { + loopback := netip.MustParseAddr("127.0.0.1") + derpPort := reserveTCPPort(t, loopback) + stunPort := reserveUDPPort(t, loopback) + _, certPath, keyPath := createSelfSignedCertificate(t, "localhost") + certPEM, err := os.ReadFile(certPath) + require.NoError(t, err) + certBlock, _ := pem.Decode(certPEM) + certificate, err := x509.ParseCertificate(certBlock.Bytes) + require.NoError(t, err) + certHash := sha256.Sum256(certificate.Raw) + + serverKey := tailscale.NewTailcatPrivateKey() + clientKey := tailscale.NewTailcatPrivateKey() + var presharedKey [32]byte + _, err = rand.Read(presharedKey[:]) + require.NoError(t, err) + derpServers := []option.TailcatDERPServer{{ + Host: "localhost", + IPv4: loopback.String(), + IPv6: "none", + DERPPort: derpPort, + STUNPort: stunPort, + CertName: "sha256-raw:" + hex.EncodeToString(certHash[:]), + }} + + startInstance(t, option.Options{ + Services: []option.Service{{ + Type: C.TypeDERP, + Tag: "derp", + Options: &option.DERPServiceOptions{ + ListenOptions: option.ListenOptions{ + Listen: (*badoption.Addr)(common.Ptr(loopback)), + ListenPort: derpPort, + }, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + CertificatePath: certPath, + KeyPath: keyPath, + }, + }, + ConfigPath: filepath.Join(t.TempDir(), "derp.key"), + VerifyClientInbound: []string{"tailcat-in"}, + STUN: &option.DERPSTUNListenOptions{ + Enabled: true, + ListenOptions: option.ListenOptions{ + Listen: (*badoption.Addr)(common.Ptr(loopback)), + ListenPort: stunPort, + }, + }, + }, + }}, + Inbounds: []option.Inbound{{ + Type: C.TypeTailcat, + Tag: "tailcat-in", + Options: &option.TailcatInboundOptions{ + PrivateKey: tailscale.EncodeTailcatKey(serverKey), + PreSharedKey: tailscale.EncodeTailcatKey(presharedKey), + Users: []option.TailcatUser{{ + Name: "client", + PublicKey: tailscale.EncodeTailcatKey(tailscale.TailcatPublicKey(clientKey)), + }}, + TailcatDERPOptions: option.TailcatDERPOptions{DERPServers: derpServers}, + }, + }}, + Outbounds: []option.Outbound{{Type: C.TypeDirect, Tag: "direct", Options: &option.DirectOutboundOptions{}}}, + Route: &option.RouteOptions{Final: "direct"}, + }) + client := startInstance(t, option.Options{ + Outbounds: []option.Outbound{{ + Type: C.TypeTailcat, + Tag: "tailcat-out", + Options: &option.TailcatOutboundOptions{ + PrivateKey: tailscale.EncodeTailcatKey(clientKey), + ServerPublicKey: tailscale.EncodeTailcatKey(tailscale.TailcatPublicKey(serverKey)), + ServerDiscoKey: tailscale.EncodeTailcatKey(tailscale.TailcatPublicKey(tailscale.TailcatDiscoPrivateKey(serverKey))), + PreSharedKey: tailscale.EncodeTailcatKey(presharedKey), + TailcatDERPOptions: option.TailcatDERPOptions{DERPServers: derpServers}, + }, + }}, + Route: &option.RouteOptions{Final: "tailcat-out"}, + }) + outbound, loaded := client.Outbound().Outbound("tailcat-out") + require.True(t, loaded) + + // IPv4 destinations exercise the NAT64 exit-node path; the server's + // own tailcat address exercises the locally served IPv6 path. + targets := []tailcatTarget{ + {name: "exit-node", listen: loopback, dial: loopback}, + {name: "server", listen: netip.IPv6Loopback(), dial: tailscale.TailcatAddress(tailscale.TailcatPublicKey(serverKey))}, + } + t.Run("data-plane", func(t *testing.T) { + for _, target := range targets { + for _, network := range []string{"tcp", "udp", "packet"} { + t.Run(target.name+"/"+network, func(t *testing.T) { + t.Parallel() + testTailcatEcho(t, outbound, target, network) + }) + } + } + }) + t.Run("on-demand", func(t *testing.T) { + outbound.(adapter.IdleConnectionKeeper).SetKeepIdleConnections(false) + testTailcatEcho(t, outbound, targets[0], "tcp") + }) + t.Run("verify-client", func(t *testing.T) { + rootCAs := x509.NewCertPool() + rootCAs.AddCert(certificate) + serverURL := "https://" + net.JoinHostPort(loopback.String(), F.ToString(derpPort)) + "/derp" + for _, testCase := range []struct { + name string + key key.NodePrivate + allowed bool + }{ + {"listed", key.NodePrivateFromRaw32(mem.B(clientKey[:])), true}, + {"unlisted", key.NewNode(), false}, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + derpClient, err := derphttp.NewClient(testCase.key, serverURL, t.Logf, netmon.NewStatic()) + require.NoError(t, err) + defer derpClient.Close() + derpClient.TLSConfig = &tls.Config{ServerName: "localhost", RootCAs: rootCAs} + err = derpClient.Connect(ctx) + require.NoError(t, err) + type recvResult struct { + message derp.ReceivedMessage + err error + } + received := make(chan recvResult, 1) + go func() { + message, recvErr := derpClient.Recv() + received <- recvResult{message, recvErr} + }() + var result recvResult + select { + case result = <-received: + case <-ctx.Done(): + require.FailNow(t, "DERP server did not answer") + } + if testCase.allowed { + require.NoError(t, result.err) + require.IsType(t, derp.ServerInfoMessage{}, result.message) + } else { + require.Error(t, result.err) + } + }) + } + }) +} + +type tailcatTarget struct { + name string + listen netip.Addr + dial netip.Addr +} + +func testTailcatEcho(t *testing.T, outbound adapter.Outbound, target tailcatTarget, network string) { + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + deadline := time.Now().Add(15 * time.Second) + if network == "tcp" { + listener, err := net.ListenTCP("tcp", net.TCPAddrFromAddrPort(netip.AddrPortFrom(target.listen, 0))) + require.NoError(t, err) + defer listener.Close() + listener.SetDeadline(deadline) + destination := M.SocksaddrFrom(target.dial, uint16(listener.Addr().(*net.TCPAddr).Port)) + client, err := outbound.DialContext(ctx, "tcp", destination) + require.NoError(t, err) + defer client.Close() + server, err := listener.Accept() + require.NoError(t, err) + defer server.Close() + client.SetDeadline(deadline) + server.SetDeadline(deadline) + payload := bytes.Repeat([]byte("tailcat-encrypted-tcp"), 8192) + for _, pair := range [][2]net.Conn{{client, server}, {server, client}} { + result := make(chan error, 1) + go func() { + _, writeErr := pair[0].Write(payload) + result <- writeErr + }() + received := make([]byte, len(payload)) + _, err = io.ReadFull(pair[1], received) + require.NoError(t, err) + require.Equal(t, payload, received) + require.NoError(t, <-result) + } + return + } + server, err := net.ListenUDP("udp", net.UDPAddrFromAddrPort(netip.AddrPortFrom(target.listen, 0))) + require.NoError(t, err) + defer server.Close() + server.SetDeadline(deadline) + destination := M.SocksaddrFrom(target.dial, uint16(server.LocalAddr().(*net.UDPAddr).Port)) + var send func([]byte) (int, error) + var receive func([]byte) (int, error) + if network == "udp" { + client, err := outbound.DialContext(ctx, "udp", destination) + require.NoError(t, err) + defer client.Close() + client.SetDeadline(deadline) + send, receive = client.Write, client.Read + } else { + client, err := outbound.ListenPacket(ctx, destination) + require.NoError(t, err) + defer client.Close() + client.SetDeadline(deadline) + send = func(payload []byte) (int, error) { return client.WriteTo(payload, destination.UDPAddr()) } + receive = func(payload []byte) (int, error) { + count, source, readErr := client.ReadFrom(payload) + if readErr == nil { + require.Equal(t, destination.String(), source.String()) + } + return count, readErr + } + } + for _, size := range []int{64, 1200} { + payload := bytes.Repeat([]byte{0x65}, size) + _, err = send(payload) + require.NoError(t, err) + received := make([]byte, 65535) + count, source, err := server.ReadFromUDP(received) + require.NoError(t, err) + require.Equal(t, payload, received[:count]) + _, err = server.WriteToUDP(payload, source) + require.NoError(t, err) + count, err = receive(received) + require.NoError(t, err) + require.Equal(t, payload, received[:count]) + } +} + +func reserveTCPPort(t *testing.T, address netip.Addr) uint16 { + listener, err := net.ListenTCP("tcp", net.TCPAddrFromAddrPort(netip.AddrPortFrom(address, 0))) + require.NoError(t, err) + port := uint16(listener.Addr().(*net.TCPAddr).Port) + require.NoError(t, listener.Close()) + return port +} + +func reserveUDPPort(t *testing.T, address netip.Addr) uint16 { + conn, err := net.ListenUDP("udp", net.UDPAddrFromAddrPort(netip.AddrPortFrom(address, 0))) + require.NoError(t, err) + port := uint16(conn.LocalAddr().(*net.UDPAddr).Port) + require.NoError(t, conn.Close()) + return port +}