mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-11-10 21:21:02 +08:00
Add tailcat support
This commit is contained in:
38 files changed
+3103
-154
No files matched your search
+4
-8
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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/) 了解详情。
|
||||||
@@ -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/) |
|
||||||
|
|||||||
@@ -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/) |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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/) 了解详情。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
t.binding = binding
|
||||||
if interfaceMonitor := t.network.InterfaceMonitor(); interfaceMonitor != nil {
|
t.server.ControlFunc = binding.control
|
||||||
interfaceFinder := t.network.InterfaceFinder()
|
t.server.ListenPacketFunc = binding.listenPacket
|
||||||
netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) {
|
t.server.DefaultRouteFunc = binding.defaultRoute
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if t.systemInterface {
|
if t.systemInterface {
|
||||||
mtu := t.systemInterfaceMTU
|
mtu := t.systemInterfaceMTU
|
||||||
if mtu == 0 {
|
if mtu == 0 {
|
||||||
@@ -367,73 +325,7 @@ 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
|
return nil
|
||||||
})
|
|
||||||
} else if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() {
|
|
||||||
if t.platformInterface.UsePlatformAutoDetectInterfaceControl() {
|
|
||||||
netns.SetControlFunc(func(network, address string, conn syscall.RawConn) error {
|
|
||||||
return control.Raw(conn, func(fileDescriptor uintptr) error {
|
|
||||||
return t.platformInterface.AutoDetectInterfaceControl(int(fileDescriptor))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
scope.Add(func() error {
|
|
||||||
netns.SetControlFunc(nil)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// NEPacketTunnelProvider sockets are excluded from tunnel routes by
|
|
||||||
// NECP; the empty override only suppresses tailscale's own
|
|
||||||
// default-interface bind, which would select the sing-box utun.
|
|
||||||
netns.SetControlFunc(func(string, string, syscall.RawConn) error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
scope.Add(func() error {
|
|
||||||
netns.SetControlFunc(nil)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bindFunc := t.network.AutoDetectInterfaceFunc()
|
|
||||||
if bindFunc != nil {
|
|
||||||
netns.SetControlFunc(bindFunc)
|
|
||||||
netns.SetListenPacketFunc(t.listenPacket)
|
|
||||||
scope.Add(func() error {
|
|
||||||
netns.SetControlFunc(nil)
|
|
||||||
netns.SetListenPacketFunc(nil)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Endpoint) listenPacket(ctx context.Context, network string, address string) (nettype.PacketConn, error) {
|
|
||||||
listenConfig := net.ListenConfig{
|
|
||||||
Control: control.Append(t.network.AutoDetectInterfaceFunc(), control.DisableUDPNetReset()),
|
|
||||||
}
|
|
||||||
packetConn, err := listenConfig.ListenPacket(ctx, network, address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
udpConn := packetConn.(*net.UDPConn)
|
|
||||||
egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{
|
|
||||||
Logger: t.logger,
|
|
||||||
Network: network,
|
|
||||||
InterfaceFinder: t.network.InterfaceFinder(),
|
|
||||||
InterfaceMonitor: t.network.InterfaceMonitor(),
|
|
||||||
IsExempt: func() bool {
|
|
||||||
return t.network.AutoRedirectOutputMark() != 0
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) {
|
|
||||||
egressPool.Close()
|
|
||||||
return udpConn, nil
|
|
||||||
}
|
|
||||||
return tun.NewUDPEgressConn(udpConn, egressPool), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Endpoint) postStart(scope *adapter.Scope) error {
|
func (t *Endpoint) postStart(scope *adapter.Scope) error {
|
||||||
@@ -442,25 +334,11 @@ func (t *Endpoint) postStart(scope *adapter.Scope) error {
|
|||||||
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()
|
|
||||||
interfaceFinder := t.network.InterfaceFinder()
|
|
||||||
var interfacesAccess sync.Mutex
|
|
||||||
lastInterfaces := interfaceFinder.Interfaces()
|
|
||||||
interfaceUpdateElement := interfaceFinder.RegisterInterfaceUpdateCallback(func(interfaces []control.Interface) {
|
|
||||||
interfacesAccess.Lock()
|
|
||||||
changed := !slices.EqualFunc(lastInterfaces, interfaces, control.Interface.Equals)
|
|
||||||
lastInterfaces = interfaces
|
|
||||||
interfacesAccess.Unlock()
|
|
||||||
if changed {
|
|
||||||
netMon.Poll()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
scope.Add(func() error {
|
scope.Add(func() error {
|
||||||
interfaceFinder.UnregisterInterfaceUpdateCallback(interfaceUpdateElement)
|
unwatch()
|
||||||
return nil
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:]))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user