From 5af56d2cfd7cb327bb4ef84bce46ce6c8e939bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 3 Jul 2026 21:33:34 +0800 Subject: [PATCH] Add snell protocol --- constant/proxy.go | 3 + docs/configuration/inbound/index.md | 1 + docs/configuration/inbound/index.zh.md | 1 + docs/configuration/inbound/snell.md | 96 +++++++++++ docs/configuration/inbound/snell.zh.md | 96 +++++++++++ docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/outbound/snell.md | 125 ++++++++++++++ docs/configuration/outbound/snell.zh.md | 124 ++++++++++++++ go.mod | 3 +- go.sum | 6 +- include/registry.go | 3 + mkdocs.yml | 2 + option/snell.go | 118 +++++++++++++ protocol/snell/inbound.go | 209 ++++++++++++++++++++++++ protocol/snell/outbound.go | 141 ++++++++++++++++ 16 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 docs/configuration/inbound/snell.md create mode 100644 docs/configuration/inbound/snell.zh.md create mode 100644 docs/configuration/outbound/snell.md create mode 100644 docs/configuration/outbound/snell.zh.md create mode 100644 option/snell.go create mode 100644 protocol/snell/inbound.go create mode 100644 protocol/snell/outbound.go diff --git a/constant/proxy.go b/constant/proxy.go index ea6517124..60bb806a0 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -11,6 +11,7 @@ const ( TypeHTTP = "http" TypeMixed = "mixed" TypeShadowsocks = "shadowsocks" + TypeSnell = "snell" TypeVMess = "vmess" TypeTrojan = "trojan" TypeNaive = "naive" @@ -67,6 +68,8 @@ func ProxyDisplayName(proxyType string) string { return "Mixed" case TypeShadowsocks: return "Shadowsocks" + case TypeSnell: + return "Snell" case TypeVMess: return "VMess" case TypeTrojan: diff --git a/docs/configuration/inbound/index.md b/docs/configuration/inbound/index.md index 274a37806..093db6524 100644 --- a/docs/configuration/inbound/index.md +++ b/docs/configuration/inbound/index.md @@ -31,6 +31,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | +| `snell` | [Snell](./snell/) | TCP | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | diff --git a/docs/configuration/inbound/index.zh.md b/docs/configuration/inbound/index.zh.md index 99f8df3bd..0f73ae957 100644 --- a/docs/configuration/inbound/index.zh.md +++ b/docs/configuration/inbound/index.zh.md @@ -31,6 +31,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | +| `snell` | [Snell](./snell/) | TCP | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | diff --git a/docs/configuration/inbound/snell.md b/docs/configuration/inbound/snell.md new file mode 100644 index 000000000..9fb6fe971 --- /dev/null +++ b/docs/configuration/inbound/snell.md @@ -0,0 +1,96 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +### Structure + +```json +{ + "type": "snell", + "tag": "snell-in", + + ... // Listen Fields + + "version": 5, + "psk": "password", + "users": [ + { + "name": "sekai", + "userkey": "user-password" + } + ], + "obfs_mode": "" +} +``` + +### Version 6 Structure + +```json +{ + "type": "snell", + "tag": "snell-in", + + ... // Listen Fields + + "version": 6, + "psk": "password", + "users": [ + { + "name": "sekai", + "userkey": "user-password" + } + ], + "mode": "" +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### version + +==Required== + +The Snell protocol version, one of `5` `6`. + +Version `5` supports HTTP obfuscation (`obfs_mode`); version `6` replaces it +with traffic shaping (`mode`) and requires a `psk` of 12 to 255 bytes. + +!!! note + + Since we intentionally do not support the QUIC proxy mode of Snell v5, the v5 wire protocol + is effectively identical to v4, so no separate v4 server or v5 client is provided. + +#### psk + +==Required== + +The pre-shared key. + +#### users + +Snell users. + +When set, the server runs in multi-user mode: each entry has a `name` (optional, used in +logs) and a `userkey` (the user's key). The top-level `psk` remains the server key. + +#### obfs_mode + +==Version 5 only== + +HTTP obfuscation mode, one of `none` `http`. + +`none` is used by default. + +#### mode + +==Version 6 only== + +Traffic shaping mode, one of `default` `unshaped` `unsafe-raw`. + +`default` is used by default. diff --git a/docs/configuration/inbound/snell.zh.md b/docs/configuration/inbound/snell.zh.md new file mode 100644 index 000000000..f0051c8fa --- /dev/null +++ b/docs/configuration/inbound/snell.zh.md @@ -0,0 +1,96 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +### 结构 + +```json +{ + "type": "snell", + "tag": "snell-in", + + ... // 监听字段 + + "version": 5, + "psk": "password", + "users": [ + { + "name": "sekai", + "userkey": "user-password" + } + ], + "obfs_mode": "" +} +``` + +### 版本 6 结构 + +```json +{ + "type": "snell", + "tag": "snell-in", + + ... // 监听字段 + + "version": 6, + "psk": "password", + "users": [ + { + "name": "sekai", + "userkey": "user-password" + } + ], + "mode": "" +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### version + +==必填== + +Snell 协议版本,`5` `6` 之一。 + +版本 `5` 支持 HTTP 混淆(`obfs_mode`);版本 `6` 以流量整形(`mode`)取而代之,并要求 +`psk` 长度为 12 到 255 字节。 + +!!! note + + 由于我们有意不支持 Snell v5 的 QUIC 代理模式,v5 的线路协议实际上与 v4 没有区别, + 因此不提供独立的 v4 服务器和 v5 客户端。 + +#### psk + +==必填== + +预共享密钥。 + +#### users + +Snell 用户。 + +设置后,服务器运行于多用户模式:每一项包含 `name`(可选,用于日志)和 `userkey` +(用户密钥)。顶层的 `psk` 仍作为服务器密钥。 + +#### obfs_mode + +==仅版本 5== + +HTTP 混淆模式,`none` `http` 之一。 + +默认为 `none`。 + +#### mode + +==仅版本 6== + +流量整形模式,`default` `unshaped` `unsafe-raw` 之一。 + +默认为 `default`。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 47b8a96a5..0411ec045 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -31,6 +31,7 @@ | `tuic` | [TUIC](./tuic/) | | `hysteria2` | [Hysteria2](./hysteria2/) | | `anytls` | [AnyTLS](./anytls/) | +| `snell` | [Snell](./snell/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index a1c4a7add..5c7b78523 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -31,6 +31,7 @@ | `tuic` | [TUIC](./tuic/) | | `hysteria2` | [Hysteria2](./hysteria2/) | | `anytls` | [AnyTLS](./anytls/) | +| `snell` | [Snell](./snell/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/snell.md b/docs/configuration/outbound/snell.md new file mode 100644 index 000000000..80d410f78 --- /dev/null +++ b/docs/configuration/outbound/snell.md @@ -0,0 +1,125 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +### Structure + +```json +{ + "type": "snell", + "tag": "snell-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": 4, + "psk": "password", + "userkey": "", + "reuse": false, + "network": "tcp", + "obfs_mode": "", + "obfs_host": "", + + ... // Dial Fields +} +``` + +### Version 6 Structure + +```json +{ + "type": "snell", + "tag": "snell-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": 6, + "psk": "password", + "userkey": "", + "reuse": false, + "network": "tcp", + "mode": "", + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### version + +==Required== + +The Snell protocol version, one of `4` `6`. + +Version `4` supports HTTP obfuscation (`obfs_mode` / `obfs_host`); version `6` +replaces it with traffic shaping (`mode`) and requires a `psk` of 12 to 255 +bytes. + +!!! note + + Since we intentionally do not support the QUIC proxy mode of Snell v5, the v5 wire protocol + is effectively identical to v4, so no separate v4 server or v5 client is provided. + +#### psk + +==Required== + +The pre-shared key. + +#### userkey + +The user key, used to authenticate against a multi-user server. + +#### reuse + +Enable connection reuse (the Snell v2 `CONNECT` command). + +#### network + +Enabled network + +One of `tcp` `udp`. + +Both is enabled by default. + +#### obfs_mode + +==Version 4 only== + +HTTP obfuscation mode, one of `none` `http`. + +`none` is used by default. + +#### obfs_host + +==Version 4 only== + +The HTTP `Host` header sent when `obfs_mode` is `http`. + +`bing.com` is used by default. + +#### mode + +==Version 6 only== + +Traffic shaping mode, one of `default` `unshaped` `unsafe-raw`. + +`default` is used by default. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/snell.zh.md b/docs/configuration/outbound/snell.zh.md new file mode 100644 index 000000000..a1526c394 --- /dev/null +++ b/docs/configuration/outbound/snell.zh.md @@ -0,0 +1,124 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +### 结构 + +```json +{ + "type": "snell", + "tag": "snell-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": 4, + "psk": "password", + "userkey": "", + "reuse": false, + "network": "tcp", + "obfs_mode": "", + "obfs_host": "", + + ... // 拨号字段 +} +``` + +### 版本 6 结构 + +```json +{ + "type": "snell", + "tag": "snell-out", + + "server": "127.0.0.1", + "server_port": 1080, + "version": 6, + "psk": "password", + "userkey": "", + "reuse": false, + "network": "tcp", + "mode": "", + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### version + +==必填== + +Snell 协议版本,`4` `6` 之一。 + +版本 `4` 支持 HTTP 混淆(`obfs_mode` / `obfs_host`);版本 `6` 以流量整形(`mode`) +取而代之,并要求 `psk` 长度为 12 到 255 字节。 + +!!! note + + 由于我们有意不支持 Snell v5 的 QUIC 代理模式,v5 的线路协议实际上与 v4 没有区别, + 因此不提供独立的 v4 服务器和 v5 客户端。 + +#### psk + +==必填== + +预共享密钥。 + +#### userkey + +用户密钥,用于向多用户服务器进行认证。 + +#### reuse + +启用连接复用(Snell v2 `CONNECT` 命令)。 + +#### network + +启用的网络协议。 + +`tcp` 或 `udp`。 + +默认所有。 + +#### obfs_mode + +==仅版本 4== + +HTTP 混淆模式,`none` `http` 之一。 + +默认为 `none`。 + +#### obfs_host + +==仅版本 4== + +`obfs_mode` 为 `http` 时发送的 HTTP `Host` 头。 + +默认为 `bing.com`。 + +#### mode + +==仅版本 6== + +流量整形模式,`default` `unshaped` `unsafe-raw` 之一。 + +默认为 `default`。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/go.mod b/go.mod index 5e17b1e86..21a6dceb0 100644 --- a/go.mod +++ b/go.mod @@ -41,13 +41,14 @@ require ( github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260702074557-63e2fd680d8a + github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d github.com/sagernet/sing-cloudflared v0.1.2 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 + github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814 github.com/sagernet/sing-tun v0.8.12-0.20260629021427-b3c6babbd353 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 diff --git a/go.sum b/go.sum index a1c9ae6a0..263bb843c 100644 --- a/go.sum +++ b/go.sum @@ -258,8 +258,8 @@ github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260702074557-63e2fd680d8a h1:I8FFJ8IOS+vUSNIq7L1pzBv0fMI507FEpe2CzmT0Jz0= -github.com/sagernet/sing v0.8.12-0.20260702074557-63e2fd680d8a/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= +github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.2 h1:rEz98+q2nvbNlQXaDXJQlRKngK81FHK+rm7pNCSLXB4= github.com/sagernet/sing-cloudflared v0.1.2/go.mod h1:bH2NKX+NpDTY1Zkxfboxw6MXB/ZywaNLmrDJYgKMJ2Y= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= @@ -272,6 +272,8 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814 h1:xfnkRpjVRVeJhVvDZA8PzTLlKGTb1o2kdI4uv1YymXo= +github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= github.com/sagernet/sing-tun v0.8.12-0.20260629021427-b3c6babbd353 h1:HA0TGrBQSFfvcoVXL1DxzF+i8pmaGOGb33jdReB7L4s= github.com/sagernet/sing-tun v0.8.12-0.20260629021427-b3c6babbd353/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= diff --git a/include/registry.go b/include/registry.go index 3bcbddb36..6ea32e1bd 100644 --- a/include/registry.go +++ b/include/registry.go @@ -29,6 +29,7 @@ import ( "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" "github.com/sagernet/sing-box/protocol/shadowtls" + "github.com/sagernet/sing-box/protocol/snell" "github.com/sagernet/sing-box/protocol/socks" "github.com/sagernet/sing-box/protocol/ssh" "github.com/sagernet/sing-box/protocol/tor" @@ -60,6 +61,7 @@ func InboundRegistry() *inbound.Registry { mixed.RegisterInbound(registry) shadowsocks.RegisterInbound(registry) + snell.RegisterInbound(registry) vmess.RegisterInbound(registry) trojan.RegisterInbound(registry) naive.RegisterInbound(registry) @@ -87,6 +89,7 @@ func OutboundRegistry() *outbound.Registry { socks.RegisterOutbound(registry) http.RegisterOutbound(registry) shadowsocks.RegisterOutbound(registry) + snell.RegisterOutbound(registry) vmess.RegisterOutbound(registry) trojan.RegisterOutbound(registry) registerNaiveOutbound(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 7a2fc012e..3623c8a52 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -159,6 +159,7 @@ nav: - TUIC: configuration/inbound/tuic.md - Hysteria2: configuration/inbound/hysteria2.md - AnyTLS: configuration/inbound/anytls.md + - Snell: configuration/inbound/snell.md - Tun: configuration/inbound/tun.md - Redirect: configuration/inbound/redirect.md - TProxy: configuration/inbound/tproxy.md @@ -180,6 +181,7 @@ nav: - TUIC: configuration/outbound/tuic.md - Hysteria2: configuration/outbound/hysteria2.md - AnyTLS: configuration/outbound/anytls.md + - Snell: configuration/outbound/snell.md - Tor: configuration/outbound/tor.md - SSH: configuration/outbound/ssh.md - DNS: configuration/outbound/dns.md diff --git a/option/snell.go b/option/snell.go new file mode 100644 index 000000000..53c8117d8 --- /dev/null +++ b/option/snell.go @@ -0,0 +1,118 @@ +package option + +import ( + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _SnellInboundOptions struct { + ListenOptions + Version int `json:"version"` + PSK string `json:"psk"` + Users []SnellUser `json:"users,omitempty"` + ObfsOptions SnellObfsServerOptions `json:"-"` + V6Options SnellV6Options `json:"-"` +} + +type SnellInboundOptions _SnellInboundOptions + +func (o *SnellInboundOptions) UnmarshalJSON(content []byte) error { + err := json.Unmarshal(content, (*_SnellInboundOptions)(o)) + if err != nil { + return err + } + var versionOptions any + switch o.Version { + case 5: + versionOptions = &o.ObfsOptions + case 6: + versionOptions = &o.V6Options + case 0: + return E.New("snell: missing version") + default: + return E.New("snell: unsupported version: ", o.Version) + } + return badjson.UnmarshallExcluded(content, (*_SnellInboundOptions)(o), versionOptions) +} + +func (o SnellInboundOptions) MarshalJSON() ([]byte, error) { + var versionOptions any + switch o.Version { + case 5: + versionOptions = o.ObfsOptions + case 6: + versionOptions = o.V6Options + case 0: + return nil, E.New("snell: missing version") + default: + return nil, E.New("snell: unsupported version: ", o.Version) + } + return badjson.MarshallObjects((_SnellInboundOptions)(o), versionOptions) +} + +type _SnellOutboundOptions struct { + DialerOptions + ServerOptions + Version int `json:"version"` + PSK string `json:"psk"` + UserKey string `json:"userkey,omitempty"` + Reuse bool `json:"reuse,omitempty"` + Network NetworkList `json:"network,omitempty"` + ObfsOptions SnellObfsClientOptions `json:"-"` + V6Options SnellV6Options `json:"-"` +} + +type SnellOutboundOptions _SnellOutboundOptions + +func (o *SnellOutboundOptions) UnmarshalJSON(content []byte) error { + err := json.Unmarshal(content, (*_SnellOutboundOptions)(o)) + if err != nil { + return err + } + var versionOptions any + switch o.Version { + case 4: + versionOptions = &o.ObfsOptions + case 6: + versionOptions = &o.V6Options + case 0: + return E.New("snell: missing version") + default: + return E.New("snell: unsupported version: ", o.Version) + } + return badjson.UnmarshallExcluded(content, (*_SnellOutboundOptions)(o), versionOptions) +} + +func (o SnellOutboundOptions) MarshalJSON() ([]byte, error) { + var versionOptions any + switch o.Version { + case 4: + versionOptions = o.ObfsOptions + case 6: + versionOptions = o.V6Options + case 0: + return nil, E.New("snell: missing version") + default: + return nil, E.New("snell: unsupported version: ", o.Version) + } + return badjson.MarshallObjects((_SnellOutboundOptions)(o), versionOptions) +} + +type SnellObfsServerOptions struct { + ObfsMode string `json:"obfs_mode,omitempty"` +} + +type SnellUser struct { + Name string `json:"name,omitempty"` + UserKey string `json:"userkey"` +} + +type SnellObfsClientOptions struct { + ObfsMode string `json:"obfs_mode,omitempty"` + ObfsHost string `json:"obfs_host,omitempty"` +} + +type SnellV6Options struct { + Mode string `json:"mode,omitempty"` +} diff --git a/protocol/snell/inbound.go b/protocol/snell/inbound.go new file mode 100644 index 000000000..45b81f43c --- /dev/null +++ b/protocol/snell/inbound.go @@ -0,0 +1,209 @@ +package snell + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + snellprotocol "github.com/sagernet/sing-snell" + "github.com/sagernet/sing-snell/snellv5" + "github.com/sagernet/sing-snell/snellv6" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.SnellInboundOptions](registry, C.TypeSnell, NewInbound) +} + +var _ adapter.TCPInjectableInbound = (*Inbound)(nil) + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service snellprotocol.Service + users []option.SnellUser +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SnellInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeSnell, tag), + router: uot.NewRouter(router, logger), + logger: logger, + users: options.Users, + } + var userList []int + var keyList [][]byte + if len(options.Users) > 0 { + userList = make([]int, len(options.Users)) + keyList = make([][]byte, len(options.Users)) + for index, user := range options.Users { + userList[index] = index + keyList[index] = []byte(user.UserKey) + } + } + var err error + switch options.Version { + case 5: + var obfsMode snellprotocol.ObfsMode + obfsMode, err = snellprotocol.ParseObfsMode(options.ObfsOptions.ObfsMode) + if err != nil { + return nil, err + } + serviceOptions := snellv5.ServiceOptions{ + PSK: []byte(options.PSK), + ObfsMode: obfsMode, + Handler: inbound, + } + if len(options.Users) > 0 { + var service *snellv5.MultiService[int] + service, err = snellv5.NewMultiService[int](serviceOptions) + if err != nil { + return nil, err + } + err = service.UpdateUsers(userList, keyList) + inbound.service = service + } else { + inbound.service, err = snellv5.NewService(serviceOptions) + } + case 6: + var mode snellv6.Mode + mode, err = snellv6.ParseMode(options.V6Options.Mode) + if err != nil { + return nil, err + } + serviceOptions := snellv6.ServerOptions{ + PSK: []byte(options.PSK), + Mode: mode, + Handler: inbound, + } + if len(options.Users) > 0 { + var service *snellv6.MultiService[int] + service, err = snellv6.NewMultiService[int](serviceOptions) + if err != nil { + return nil, err + } + err = service.UpdateUsers(userList, keyList) + inbound.service = service + } else { + inbound.service, err = snellv6.NewService(serviceOptions) + } + case 0: + return nil, E.New("snell: missing version") + default: + return nil, E.New("snell: unsupported version: ", options.Version) + } + if err != nil { + return nil, err + } + inbound.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: inbound, + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return h.listener.Close() +} + +func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + _, metadata := adapter.ExtendContext(ctx) + if source.IsValid() { + metadata.Source = source + } + if destination.IsValid() { + metadata.Destination = destination + } + h.newConnection(ctx, conn, *metadata, onClose) +} + +func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + _, metadata := adapter.ExtendContext(ctx) + if source.IsValid() { + metadata.Source = source + } + if destination.IsValid() { + metadata.Destination = destination + } + h.newPacketConnection(ctx, conn, *metadata, onClose) +} + +func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + if len(h.users) > 0 { + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + if len(h.users) > 0 { + userIndex, loaded := auth.UserFromContext[int](ctx) + if !loaded { + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + user := h.users[userIndex].Name + if user == "" { + user = F.ToString(userIndex) + } else { + metadata.User = user + } + h.logger.InfoContext(ctx, "[", user, "] inbound packet connection from ", metadata.Source) + } else { + h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/snell/outbound.go b/protocol/snell/outbound.go new file mode 100644 index 000000000..74543f8f5 --- /dev/null +++ b/protocol/snell/outbound.go @@ -0,0 +1,141 @@ +package snell + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "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" + snellprotocol "github.com/sagernet/sing-snell" + "github.com/sagernet/sing-snell/snellv4" + "github.com/sagernet/sing-snell/snellv6" + "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" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.SnellOutboundOptions](registry, C.TypeSnell, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + dialer N.Dialer + client snellClient + serverAddr M.Socksaddr +} + +type snellClient interface { + snellprotocol.Method + DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) + Close() error +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SnellOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + serverAddr := options.ServerOptions.Build() + var client snellClient + switch options.Version { + case 4: + var obfsMode snellprotocol.ObfsMode + obfsMode, err = snellprotocol.ParseObfsMode(options.ObfsOptions.ObfsMode) + if err != nil { + return nil, err + } + client, err = snellv4.NewClient(snellv4.ClientOptions{ + PSK: []byte(options.PSK), + UserKey: []byte(options.UserKey), + Reuse: options.Reuse, + ObfsMode: obfsMode, + ObfsHost: options.ObfsOptions.ObfsHost, + Dialer: outboundDialer, + Server: serverAddr, + }) + case 6: + var mode snellv6.Mode + mode, err = snellv6.ParseMode(options.V6Options.Mode) + if err != nil { + return nil, err + } + client, err = snellv6.NewClient(snellv6.ClientOptions{ + PSK: []byte(options.PSK), + UserKey: []byte(options.UserKey), + Mode: mode, + Reuse: options.Reuse, + Dialer: outboundDialer, + Server: serverAddr, + }) + case 0: + return nil, E.New("snell: missing version") + default: + return nil, E.New("snell: unsupported version: ", options.Version) + } + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSnell, tag, options.Network.Build(), options.DialerOptions), + logger: logger, + dialer: outboundDialer, + client: client, + serverAddr: serverAddr, + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + networkName := N.NetworkName(network) + switch networkName { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialContext(ctx, destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + conn, err := h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + if err != nil { + return nil, err + } + packetConn, err := h.client.DialPacketConn(conn) + if err != nil { + conn.Close() + return nil, err + } + return bufio.NewBindPacketConn(packetConn, destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + conn, err := h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) + if err != nil { + return nil, err + } + packetConn, err := h.client.DialPacketConn(conn) + if err != nil { + conn.Close() + return nil, err + } + return packetConn, nil +} + +func (h *Outbound) Close() error { + return h.client.Close() +}