Add tailcat support

This commit is contained in:
世界 committed 2026-11-09 15:17:20 +00:00
1 parent 6ba86964d0
commit 15a9be9a1d
38 files changed
+3108 -159

No files matched your search

+4 -8
View File
@@ -49,7 +49,7 @@ type PreMatchResult struct {
NewTracker func() tun.FlowTracker 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 var networkName string
switch network { switch network {
case uint8(header.TCPProtocolNumber): case uint8(header.TCPProtocolNumber):
@@ -61,13 +61,9 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8,
default: default:
return tun.FlowVerdict{Action: tun.ActionAccept} return tun.FlowVerdict{Action: tun.ActionAccept}
} }
metadata := InboundContext{ metadata.Network = networkName
Inbound: inbound, metadata.Source = M.SocksaddrFromNetIP(source)
InboundType: inboundType, metadata.Destination = M.SocksaddrFromNetIP(destination)
Network: networkName,
Source: M.SocksaddrFromNetIP(source),
Destination: M.SocksaddrFromNetIP(destination),
}
if networkName == N.NetworkICMP { if networkName == N.NetworkICMP {
metadata.Source.Port = 0 metadata.Source.Port = 0
metadata.Destination.Port = 0 metadata.Destination.Port = 0
+42
View File
@@ -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
}
+3
View File
@@ -30,6 +30,7 @@ const (
TypeOpenVPNClient = "openvpn-client" TypeOpenVPNClient = "openvpn-client"
TypeOpenVPNServer = "openvpn-server" TypeOpenVPNServer = "openvpn-server"
TypeTailscale = "tailscale" TypeTailscale = "tailscale"
TypeTailcat = "tailcat"
TypeCloudflared = "cloudflared" TypeCloudflared = "cloudflared"
TypeDERP = "derp" TypeDERP = "derp"
TypeResolved = "resolved" TypeResolved = "resolved"
@@ -110,6 +111,8 @@ func ProxyDisplayName(proxyType string) string {
return "OpenVPN Server" return "OpenVPN Server"
case TypeTailscale: case TypeTailscale:
return "Tailscale" return "Tailscale"
case TypeTailcat:
return "Tailcat"
case TypeCloudflared: case TypeCloudflared:
return "Cloudflared" return "Cloudflared"
case TypeSelector: case TypeSelector:
+1
View File
@@ -36,6 +36,7 @@
| `redirect` | [Redirect](./redirect/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: |
| `tproxy` | [TProxy](./tproxy/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: |
| `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: | | `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: |
| `tailcat` | [Tailcat](./tailcat/) | :material-close: |
#### tag #### tag
+1
View File
@@ -36,6 +36,7 @@
| `redirect` | [Redirect](./redirect/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: |
| `tproxy` | [TProxy](./tproxy/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: |
| `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: | | `cloudflared` | [Cloudflared](./cloudflared/) | :material-close: |
| `tailcat` | [Tailcat](./tailcat/) | :material-close: |
#### tag #### tag
+98
View File
@@ -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.
+96
View File
@@ -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/) 了解详情。
+1
View File
@@ -33,6 +33,7 @@
| `hysteria2` | [Hysteria2](./hysteria2/) | | `hysteria2` | [Hysteria2](./hysteria2/) |
| `anytls` | [AnyTLS](./anytls/) | | `anytls` | [AnyTLS](./anytls/) |
| `snell` | [Snell](./snell/) | | `snell` | [Snell](./snell/) |
| `tailcat` | [Tailcat](./tailcat/) |
| `tor` | [Tor](./tor/) | | `tor` | [Tor](./tor/) |
| `ssh` | [SSH](./ssh/) | | `ssh` | [SSH](./ssh/) |
| `dns` | [DNS](./dns/) | | `dns` | [DNS](./dns/) |
+1
View File
@@ -33,6 +33,7 @@
| `hysteria2` | [Hysteria2](./hysteria2/) | | `hysteria2` | [Hysteria2](./hysteria2/) |
| `anytls` | [AnyTLS](./anytls/) | | `anytls` | [AnyTLS](./anytls/) |
| `snell` | [Snell](./snell/) | | `snell` | [Snell](./snell/) |
| `tailcat` | [Tailcat](./tailcat/) |
| `tor` | [Tor](./tor/) | | `tor` | [Tor](./tor/) |
| `ssh` | [SSH](./ssh/) | | `ssh` | [SSH](./ssh/) |
| `dns` | [DNS](./dns/) | | `dns` | [DNS](./dns/) |
+84
View File
@@ -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.
+84
View File
@@ -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/) 了解详情。
+14
View File
@@ -20,6 +20,8 @@ DERP service is a Tailscale DERP server, similar to [derper](https://pkg.go.dev/
"config_path": "", "config_path": "",
"verify_client_endpoint": [], "verify_client_endpoint": [],
"verify_client_url": [], "verify_client_url": [],
"verify_client_inbound": [],
"verify_client_key": [],
"home": "", "home": "",
"mesh_with": [], "mesh_with": [],
"mesh_psk": "", "mesh_psk": "",
@@ -70,6 +72,18 @@ Setting Array value to a string `__URL__` is equivalent to configuring:
{ "url": __URL__ } { "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 #### 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 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
+14
View File
@@ -20,6 +20,8 @@ DERP 服务是一个 Tailscale DERP 服务器,类似于 [derper](https://pkg.g
"config_path": "", "config_path": "",
"verify_client_endpoint": [], "verify_client_endpoint": [],
"verify_client_url": [], "verify_client_url": [],
"verify_client_inbound": [],
"verify_client_key": [],
"home": "", "home": "",
"mesh_with": [], "mesh_with": [],
"mesh_psk": "", "mesh_psk": "",
@@ -70,6 +72,18 @@ Derper 配置文件路径。
{ "url": __URL__ } { "url": __URL__ }
``` ```
#### verify_client_inbound
!!! question "自 sing-box 1.15.0 起"
用于验证客户端的 Tailcat 入站标签。
#### verify_client_key
!!! question "自 sing-box 1.15.0 起"
用于验证客户端的 Tailcat 公钥。
#### home #### home
在根路径提供的内容。可以留空(默认值,显示默认主页)、`blank` 显示空白页面,或一个重定向的 URL。 在根路径提供的内容。可以留空(默认值,显示默认主页)、`blank` 显示空白页面,或一个重定向的 URL。
+384
View File
@@ -7169,6 +7169,179 @@
], ],
"additionalProperties": false "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", "type": "object",
"properties": { "properties": {
@@ -12842,6 +13015,179 @@
], ],
"additionalProperties": false "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", "type": "object",
"properties": { "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": { "home": {
"type": "string" "type": "string"
}, },
@@ -16793,6 +17165,18 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"TailcatUser": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"public_key": {
"type": "string"
}
},
"additionalProperties": false
},
"TrojanUser": { "TrojanUser": {
"type": "object", "type": "object",
"properties": { "properties": {
+1 -1
View File
@@ -66,6 +66,7 @@ require (
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55
github.com/vishvananda/netns v0.0.5 github.com/vishvananda/netns v0.0.5
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
@@ -187,7 +188,6 @@ require (
github.com/zeebo/blake3 v0.2.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.3.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/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect golang.org/x/tools v0.47.0 // indirect
+2
View File
@@ -72,6 +72,7 @@ func InboundRegistry() *inbound.Registry {
registerQUICInbounds(registry) registerQUICInbounds(registry)
registerCloudflaredInbound(registry) registerCloudflaredInbound(registry)
registerTailcatInbound(registry)
registerStubForRemovedInbounds(registry) registerStubForRemovedInbounds(registry)
return registry return registry
@@ -102,6 +103,7 @@ func OutboundRegistry() *outbound.Registry {
anytls.RegisterOutbound(registry) anytls.RegisterOutbound(registry)
registerQUICOutbounds(registry) registerQUICOutbounds(registry)
registerTailcatOutbound(registry)
registerStubForRemovedOutbounds(registry) registerStubForRemovedOutbounds(registry)
return registry return registry
+10
View File
@@ -5,6 +5,8 @@ package include
import ( import (
"github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/adapter/endpoint" "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/adapter/service"
"github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/protocol/tailscale" "github.com/sagernet/sing-box/protocol/tailscale"
@@ -15,6 +17,14 @@ func registerTailscaleEndpoint(registry *endpoint.Registry) {
tailscale.RegisterEndpoint(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) { func registerTailscaleTransport(registry *dns.TransportRegistry) {
tailscale.RegistryTransport(registry) tailscale.RegistryTransport(registry)
} }
+14
View File
@@ -8,6 +8,8 @@ import (
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/adapter/endpoint" "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/adapter/service"
C "github.com/sagernet/sing-box/constant" C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns" "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) { 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) { 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`) return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`)
+2
View File
@@ -179,6 +179,7 @@ nav:
- Redirect: configuration/inbound/redirect.md - Redirect: configuration/inbound/redirect.md
- TProxy: configuration/inbound/tproxy.md - TProxy: configuration/inbound/tproxy.md
- Cloudflared: configuration/inbound/cloudflared.md - Cloudflared: configuration/inbound/cloudflared.md
- Tailcat: configuration/inbound/tailcat.md
- Outbound: - Outbound:
- configuration/outbound/index.md - configuration/outbound/index.md
- Direct: configuration/outbound/direct.md - Direct: configuration/outbound/direct.md
@@ -198,6 +199,7 @@ nav:
- Hysteria2: configuration/outbound/hysteria2.md - Hysteria2: configuration/outbound/hysteria2.md
- AnyTLS: configuration/outbound/anytls.md - AnyTLS: configuration/outbound/anytls.md
- Snell: configuration/outbound/snell.md - Snell: configuration/outbound/snell.md
- Tailcat: configuration/outbound/tailcat.md
- Tor: configuration/outbound/tor.md - Tor: configuration/outbound/tor.md
- SSH: configuration/outbound/ssh.md - SSH: configuration/outbound/ssh.md
- DNS: configuration/outbound/dns.md - DNS: configuration/outbound/dns.md
+74
View File
@@ -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
}
+2
View File
@@ -86,6 +86,8 @@ type DERPServiceOptions struct {
ConfigPath string `json:"config_path,omitempty"` ConfigPath string `json:"config_path,omitempty"`
VerifyClientEndpoint badoption.Listable[string] `json:"verify_client_endpoint,omitempty"` VerifyClientEndpoint badoption.Listable[string] `json:"verify_client_endpoint,omitempty"`
VerifyClientURL badoption.Listable[*DERPVerifyClientURLOptions] `json:"verify_client_url,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"` Home string `json:"home,omitempty"`
MeshWith badoption.Listable[*DERPMeshOptions] `json:"mesh_with,omitempty"` MeshWith badoption.Listable[*DERPMeshOptions] `json:"mesh_with,omitempty"`
MeshPSK string `json:"mesh_psk,omitempty"` MeshPSK string `json:"mesh_psk,omitempty"`
+1 -1
View File
@@ -97,7 +97,7 @@ func judgeOpenConnectFlow(router adapter.Router, tag string, endpointType string
return tun.FlowVerdict{Action: tun.ActionAccept} 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) { func materialSource(name string, inlineValues []string, path string) (openconnect.Material, error) {
+1 -1
View File
@@ -103,7 +103,7 @@ func judgeOpenVPNFlow(router adapter.Router, tag string, endpointType string, lo
return tun.FlowVerdict{Action: tun.ActionAccept} 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) { func keyDirectionValue(direction string) (int, error) {
+13 -135
View File
@@ -16,7 +16,6 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
@@ -33,7 +32,6 @@ import (
"github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun"
"github.com/sagernet/sing/common" "github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio" "github.com/sagernet/sing/common/bufio"
"github.com/sagernet/sing/common/control"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format" F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/logger"
@@ -48,13 +46,10 @@ import (
"github.com/sagernet/tailscale/ipn/ipnlocal" "github.com/sagernet/tailscale/ipn/ipnlocal"
tsDNS "github.com/sagernet/tailscale/net/dns" tsDNS "github.com/sagernet/tailscale/net/dns"
nDNSResolver "github.com/sagernet/tailscale/net/dns/resolver" 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" "github.com/sagernet/tailscale/net/tsaddr"
tsTUN "github.com/sagernet/tailscale/net/tstun" tsTUN "github.com/sagernet/tailscale/net/tstun"
"github.com/sagernet/tailscale/tailcfg" "github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet" "github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/types/nettype"
"github.com/sagernet/tailscale/version" "github.com/sagernet/tailscale/version"
"github.com/sagernet/tailscale/wgengine" "github.com/sagernet/tailscale/wgengine"
"github.com/sagernet/tailscale/wgengine/router" "github.com/sagernet/tailscale/wgengine/router"
@@ -91,6 +86,7 @@ type Endpoint struct {
network adapter.NetworkManager network adapter.NetworkManager
platformInterface adapter.PlatformInterface platformInterface adapter.PlatformInterface
detour string detour string
binding systemBinding
server *tsnet.Server server *tsnet.Server
stack *tun.Go stack *tun.Go
returnAccess sync.Mutex 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 { func (t *Endpoint) start(scope *adapter.Scope) error {
if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { binding, err := newSystemBinding(t.ctx, t.logger)
err := t.network.UpdateInterfaces() if err != nil {
if err != nil { return err
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
})
} }
t.binding = binding
t.server.ControlFunc = binding.control
t.server.ListenPacketFunc = binding.listenPacket
t.server.DefaultRouteFunc = binding.defaultRoute
if t.systemInterface { if t.systemInterface {
mtu := t.systemInterfaceMTU mtu := t.systemInterfaceMTU
if mtu == 0 { if mtu == 0 {
@@ -367,100 +325,20 @@ func (t *Endpoint) start(scope *adapter.Scope) error {
t.systemDialer = systemDialer t.systemDialer = systemDialer
t.server.Tun = wgTunDevice 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 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 { func (t *Endpoint) postStart(scope *adapter.Scope) error {
err := t.server.Start() err := t.server.Start()
if err != nil { if err != nil {
return err return err
} }
scope.Add(t.server.Close) scope.Add(t.server.Close)
if t.network.InterfaceMonitor() != nil { unwatch := t.binding.watch(t.server.Sys().NetMon.Get())
netMon := t.server.Sys().NetMon.Get() scope.Add(func() error {
interfaceFinder := t.network.InterfaceFinder() unwatch()
var interfacesAccess sync.Mutex return nil
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
})
}
localBackend := t.server.ExportLocalBackend() localBackend := t.server.ExportLocalBackend()
t.localBackend.Store(localBackend) t.localBackend.Store(localBackend)
scope.Add(func() error { scope.Add(func() error {
+1 -1
View File
@@ -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) { func (t *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
+149
View File
@@ -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)
}
}
+171
View File
@@ -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
}
+101
View File
@@ -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:]))
}
+248
View File
@@ -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)
}
+74
View File
@@ -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
}
+639
View File
@@ -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()
}
}
+438
View File
@@ -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
}
+1 -1
View File
@@ -611,7 +611,7 @@ func (t *Inbound) JudgeFlow(network uint8, source netip.AddrPort, destination ne
} }
return tun.FlowVerdict{Action: tun.ActionAccept} 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 { func (t *Inbound) isDNSHijackDestination(destination M.Socksaddr) bool {
+1 -1
View File
@@ -221,7 +221,7 @@ func (w *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n
return tun.FlowVerdict{Action: tun.ActionAccept} 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) { func (w *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
+40
View File
@@ -14,6 +14,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
@@ -48,6 +49,7 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/go-chi/render" "github.com/go-chi/render"
"go4.org/mem"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/net/http2/h2c" //nolint:staticcheck "golang.org/x/net/http2/h2c" //nolint:staticcheck
) )
@@ -67,6 +69,8 @@ type Service struct {
configPath string configPath string
verifyClientEndpoint []string verifyClientEndpoint []string
verifyClientURL []*option.DERPVerifyClientURLOptions verifyClientURL []*option.DERPVerifyClientURLOptions
verifyClientInbound []string
verifyClientKeys []key.NodePublic
home string home string
meshKey string meshKey string
meshKeyPath 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 var stunListener *listener.Listener
if options.STUN != nil && options.STUN.Enabled { if options.STUN != nil && options.STUN.Enabled {
if options.STUN.Listen == nil { if options.STUN.Listen == nil {
@@ -127,6 +140,8 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
configPath: configPath, configPath: configPath,
verifyClientEndpoint: options.VerifyClientEndpoint, verifyClientEndpoint: options.VerifyClientEndpoint,
verifyClientURL: options.VerifyClientURL, verifyClientURL: options.VerifyClientURL,
verifyClientInbound: options.VerifyClientInbound,
verifyClientKeys: verifyClientKeys,
home: options.Home, home: options.Home,
meshKey: options.MeshPSK, meshKey: options.MeshPSK,
meshKeyPath: options.MeshPSKFile, meshKeyPath: options.MeshPSKFile,
@@ -162,6 +177,30 @@ func (d *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error {
server.SetVerifyClientURL(urls) 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 != "" { if d.meshKey != "" {
server.SetMeshKey(d.meshKey) server.SetMeshKey(d.meshKey)
} else if d.meshKeyPath != "" { } else if d.meshKeyPath != "" {
@@ -229,6 +268,7 @@ func (d *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error {
if err != nil { if err != nil {
return err return err
} }
scope.Add(d.stunListener.Close)
go d.loopSTUNPacket(stunConn.(*net.UDPConn)) go d.loopSTUNPacket(stunConn.(*net.UDPConn))
} }
case adapter.StartStatePostStart: case adapter.StartStatePostStart:
+4 -6
View File
@@ -18,9 +18,11 @@ require (
github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks v0.2.8
github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowsocks2 v0.2.1
github.com/sagernet/sing-tun v0.9.4-0.20260916041232-89d2e01a748a 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/spyzhov/ajson v0.9.4
github.com/stretchr/testify v1.12.0 github.com/stretchr/testify v1.12.0
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/net v0.57.0 golang.org/x/net v0.57.0
) )
@@ -115,7 +117,7 @@ require (
github.com/pkg/sftp v1.13.10 // indirect github.com/pkg/sftp v1.13.10 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/safchain/ethtool v0.3.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/cors v1.2.1 // indirect
github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 // indirect github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 // indirect
github.com/sagernet/cronet-go/all 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-usbip v0.0.0-20260817040617-28bd42667eca // indirect
github.com/sagernet/sing-vmess v0.2.8 // 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/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.8-0.20260917055437-bc6039037038 // indirect
github.com/sagernet/wireguard-go v0.0.6 // indirect
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect
github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // 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/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // 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 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.54.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // 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/sing-tun => ../../sing-tun
replace github.com/sagernet/tailscale => ../../tailscale
+6 -4
View File
@@ -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/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 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 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-20260915102804-500ee1e84832 h1:KDXMGbzqLmDdKcld0T3wuMuqf1iezZq+DemgtZ/vhtQ=
github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= 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 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= 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= 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/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 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= 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/tailscale v1.102.1-sing-box-1.14-mod.5.0.20260916060041-a8fbeb4b0838 h1:YlcCeRWjFIp6dqxvfdH0i6ku17Lv3YzVuHY5qOA7Nv8=
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/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 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+288
View File
@@ -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
}