From e363c2ff784bc0cecbee87f99349e5715c613dd6 Mon Sep 17 00:00:00 2001 From: Shtorm <108103062+shtorm-7@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:54:26 +0300 Subject: [PATCH] Add Mieru inbound, refactor sudoku. Fixes --- .gitignore | 9 +- docs/configuration/inbound/index.md | 3 +- docs/configuration/inbound/index.zh.md | 3 +- docs/configuration/inbound/mieru.md | 49 ++++ docs/configuration/inbound/mieru.zh.md | 49 ++++ docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/outbound/mieru.md | 7 +- docs/configuration/outbound/mieru.zh.md | 7 +- examples/mieru/client.json | 8 +- examples/mieru/server.json | 29 ++ examples/sudoku/client.json | 14 + examples/sudoku/client_tls.json | 26 +- examples/sudoku/server.json | 12 + go.mod | 2 +- go.sum | 4 +- include/registry.go | 2 +- option/mieru.go | 14 + option/sudoku.go | 43 +-- protocol/mieru/inbound.go | 340 +++++++++++++++++++++++ protocol/mieru/outbound.go | 44 ++- protocol/sudoku/inbound.go | 15 +- protocol/sudoku/outbound.go | 289 ++----------------- protocol/trusttunnel/inbound.go | 2 +- route/conn.go | 2 +- test/go.mod | 2 +- test/go.sum | 4 +- transport/sudoku/client.go | 293 +++++++++++++++++++ transport/sudoku/obfs/httpmask/tunnel.go | 12 +- transport/trusttunnel/quic.go | 3 + 30 files changed, 947 insertions(+), 342 deletions(-) create mode 100644 docs/configuration/inbound/mieru.md create mode 100644 docs/configuration/inbound/mieru.zh.md create mode 100644 examples/mieru/server.json create mode 100644 protocol/mieru/inbound.go create mode 100644 transport/sudoku/client.go diff --git a/.gitignore b/.gitignore index e36b4dc6..94af8cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,11 @@ CLAUDE.md AGENTS.md /.claude/ dist -logs \ No newline at end of file +logs +/*.so +/*.log +/*.db-shm +/*.db-wal +/*.db.backup.* +/test_download.bin +/wget-log \ No newline at end of file diff --git a/docs/configuration/inbound/index.md b/docs/configuration/inbound/index.md index 27cc9fdb..8be7b928 100644 --- a/docs/configuration/inbound/index.md +++ b/docs/configuration/inbound/index.md @@ -31,10 +31,11 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | +| `mieru` | [Mieru](./mieru/) | :material-close: | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | #### tag -The tag of the inbound. \ No newline at end of file +The tag of the inbound. diff --git a/docs/configuration/inbound/index.zh.md b/docs/configuration/inbound/index.zh.md index 1e0c0c4f..a2585e79 100644 --- a/docs/configuration/inbound/index.zh.md +++ b/docs/configuration/inbound/index.zh.md @@ -31,10 +31,11 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | +| `mieru` | [Mieru](./mieru/) | :material-close: | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | #### tag -入站的标签。 \ No newline at end of file +入站的标签。 diff --git a/docs/configuration/inbound/mieru.md b/docs/configuration/inbound/mieru.md new file mode 100644 index 00000000..5c422005 --- /dev/null +++ b/docs/configuration/inbound/mieru.md @@ -0,0 +1,49 @@ +--- +icon: material/new-box +--- + +### Structure + +```json +{ + "type": "mieru", + "tag": "mieru-in", + + ... // Listen Fields + + "transport": "TCP", + "users": [ + { + "name": "asdf", + "password": "hjkl" + } + ], + "traffic_pattern": "GgQIARAK", +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### transport + +==Required== + +Transmission protocol. Allowed values are `TCP` and `UDP`. + +#### users + +==Required== + +A list of mieru user name and password. + +#### traffic_pattern + +A base64 string to fine tune network behavior. + +#### user_hint_is_mandatory + +If proxy client doesn't sent user hint, proxy server will refuse the connection. diff --git a/docs/configuration/inbound/mieru.zh.md b/docs/configuration/inbound/mieru.zh.md new file mode 100644 index 00000000..a389db27 --- /dev/null +++ b/docs/configuration/inbound/mieru.zh.md @@ -0,0 +1,49 @@ +--- +icon: material/new-box +--- + +### 结构 + +```json +{ + "type": "mieru", + "tag": "mieru-in", + + ... // 监听字段 + + "transport": "TCP", + "users": [ + { + "name": "asdf", + "password": "hjkl" + } + ], + "traffic_pattern": "GgQIARAK", +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### transport + +==必填== + +通信协议。可设为 `TCP` 或 `UDP`。 + +#### users + +==必填== + +一组 mieru 用户名和密码。 + +#### traffic_pattern + +一个 base64 字符串用于微调网络行为。 + +#### user_hint_is_mandatory + +客户端若不发送用户提示,代理服务器将拒绝连接。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index da11bee7..5d3c4312 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -32,6 +32,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | | `mieru` | [Mieru](./mieru/) | | `anytls` | [AnyTLS](./anytls/) | +| `mieru` | [Mieru](./mieru/) | | `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 f049accb..f583a9bd 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -32,6 +32,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | | `mieru` | [Mieru](./mieru/) | | `anytls` | [AnyTLS](./anytls/) | +| `mieru` | [Mieru](./mieru/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/mieru.md b/docs/configuration/outbound/mieru.md index 135403f0..22851fcb 100644 --- a/docs/configuration/outbound/mieru.md +++ b/docs/configuration/outbound/mieru.md @@ -19,6 +19,7 @@ icon: material/new-box "username": "asdf", "password": "hjkl", "multiplexing": "MULTIPLEXING_LOW", + "traffic_pattern": "GgQIARAK", ... // Dial Fields } @@ -48,7 +49,7 @@ Must set at least one field between `server_port` and `server_ports`. ==Required== -Transmission protocol. The only allowed value is `TCP`. +Transmission protocol. Allowed values are `TCP` and `UDP`. #### username @@ -66,6 +67,10 @@ mieru password. Multiplexing level. Supported values are `MULTIPLEXING_OFF`, `MULTIPLEXING_LOW`, `MULTIPLEXING_MIDDLE`, `MULTIPLEXING_HIGH`. `MULTIPLEXING_OFF` disables multiplexing. +#### traffic_pattern + +A base64 string to fine tune network behavior. + ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/mieru.zh.md b/docs/configuration/outbound/mieru.zh.md index fe9b37b8..9fdb52fa 100644 --- a/docs/configuration/outbound/mieru.zh.md +++ b/docs/configuration/outbound/mieru.zh.md @@ -19,6 +19,7 @@ icon: material/new-box "username": "asdf", "password": "hjkl", "multiplexing": "MULTIPLEXING_LOW", + "traffic_pattern": "GgQIARAK", ... // 拨号字段 } @@ -48,7 +49,7 @@ icon: material/new-box ==必填== -通信协议。仅可设为 `TCP`。 +通信协议。可设为 `TCP` 或 `UDP`。 #### username @@ -66,6 +67,10 @@ mieru 密码。 多路复用设置。可以设为 `MULTIPLEXING_OFF`,`MULTIPLEXING_LOW`,`MULTIPLEXING_MIDDLE`,`MULTIPLEXING_HIGH`。其中 `MULTIPLEXING_OFF` 会关闭多路复用功能。 +#### traffic_pattern + +一个 base64 字符串用于微调网络行为。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/examples/mieru/client.json b/examples/mieru/client.json index 4a6b672c..d919e26e 100644 --- a/examples/mieru/client.json +++ b/examples/mieru/client.json @@ -27,14 +27,16 @@ "tag": "mieru-out", "server": "example.com", "server_port": 27017, - "server_ports": "27017-27019", + "server_ports": [ + "27017-27019" + ], "transport": "TCP", "username": "username", "password": "password", // valid: MULTIPLEXING_DEFAULT / MULTIPLEXING_OFF / MULTIPLEXING_LOW // MULTIPLEXING_MIDDLE / MULTIPLEXING_HIGH - "multiplexing": "MULTIPLEXING_LOW" - // Dial Fields + "multiplexing": "MULTIPLEXING_LOW", + "traffic_pattern": "GgQIARAK" } ], "route": { diff --git a/examples/mieru/server.json b/examples/mieru/server.json new file mode 100644 index 00000000..b3617d80 --- /dev/null +++ b/examples/mieru/server.json @@ -0,0 +1,29 @@ +{ + "log": { + "level": "error" + }, + "inbounds": [ + { + "type": "mieru", + "tag": "mieru-in", + "listen_port": 27017, + "transport": "TCP", + "users": [ + { + "name": "username", + "password": "password" + } + ], + "traffic_pattern": "GgQIARAK" + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct" + } +} diff --git a/examples/sudoku/client.json b/examples/sudoku/client.json index 3c47758b..b353e59f 100644 --- a/examples/sudoku/client.json +++ b/examples/sudoku/client.json @@ -12,6 +12,20 @@ "server": "your-server.com", "server_port": 443, "key": "your-secret-key" + // "aead_method": "chacha20-poly1305", // chacha20-poly1305 | aes-128-gcm | none + // "table_type": "prefer_ascii", // prefer_ascii | prefer_entropy | up_ascii_down_entropy | up_entropy_down_ascii + // "padding_min": 10, // 0-100 + // "padding_max": 30, // 0-100, >= padding_min + // "enable_pure_downlink": true, // true | false + // "custom_table": "xpxvvpvv", // 8 chars: 2x, 2p, 4v + // "custom_tables": ["xpxvvpvv", "vxpvxvvp"], + // "http_mask": { + // "enabled": true, // true | false + // "mode": "stream", // legacy | stream | poll | auto | ws + // "host": "cdn.example.com", // optional, Host header / SNI override + // "path_root": "secret", // optional, URL path prefix (single segment) + // "multiplex": "auto" // off | auto | on + // } } ] } diff --git a/examples/sudoku/client_tls.json b/examples/sudoku/client_tls.json index 1d0ee27a..f341fafe 100644 --- a/examples/sudoku/client_tls.json +++ b/examples/sudoku/client_tls.json @@ -12,17 +12,23 @@ "server": "your-server.com", "server_port": 443, "key": "your-secret-key", - "tls": { - "enabled": true, - "fragment": true, - "fragment_fallback_delay": "300ms" - }, + // "aead_method": "chacha20-poly1305", // chacha20-poly1305 | aes-128-gcm | none + // "table_type": "prefer_ascii", // prefer_ascii | prefer_entropy | up_ascii_down_entropy | up_entropy_down_ascii + // "padding_min": 10, // 0-100 + // "padding_max": 30, // 0-100, >= padding_min + // "enable_pure_downlink": true, // true | false + // "custom_table": "xpxvvpvv", // 8 chars: 2x, 2p, 4v + // "custom_tables": ["xpxvvpvv", "vxpvxvvp"], "http_mask": { - "enabled": true, - "mode": "stream", - "host": "cdn.example.com", - "path_root": "secret", - "multiplex": "auto" + "enabled": true, // true | false + "mode": "stream", // legacy | stream | poll | auto | ws + "host": "cdn.example.com", // optional, Host header / SNI override + "path_root": "secret", // optional, URL path prefix (single segment) + "multiplex": "auto", // off | auto | on + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#outbound + "enabled": true, + "server_name": "cdn.example.com", + } } } ] diff --git a/examples/sudoku/server.json b/examples/sudoku/server.json index a93a8f94..a344ad61 100644 --- a/examples/sudoku/server.json +++ b/examples/sudoku/server.json @@ -5,6 +5,18 @@ "listen": "::", "listen_port": 443, "key": "your-secret-key" + // "aead_method": "chacha20-poly1305", // chacha20-poly1305 | aes-128-gcm | none + // "table_type": "prefer_ascii", // prefer_ascii | prefer_entropy | up_ascii_down_entropy | up_entropy_down_ascii + // "padding_min": 10, // 0-100 + // "padding_max": 30, // 0-100, >= padding_min + // "enable_pure_downlink": true, // true | false + // "handshake_timeout": 5, // seconds + // "custom_table": "xpxvvpvv", // 8 chars: 2x, 2p, 4v + // "custom_tables": ["xpxvvpvv", "vxpvxvvp"], + // "disable_http_mask": false, // true | false + // "http_mask_mode": "legacy", // legacy | stream | poll | auto | ws + // "path_root": "secret", // optional, URL path prefix (single segment) + // "fallback": "127.0.0.1:8080" // optional, fallback address for rejected connections } ], "outbounds": [ diff --git a/go.mod b/go.mod index 749e222a..103f8535 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/coder/websocket v1.8.14 github.com/cretz/bine v0.2.0 github.com/database64128/tfo-go/v2 v2.3.2 - github.com/enfein/mieru/v3 v3.17.1 + github.com/enfein/mieru/v3 v3.33.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/render v1.0.3 github.com/go-playground/validator/v10 v10.30.1 diff --git a/go.sum b/go.sum index 466266ab..d39aeb33 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBHY= -github.com/enfein/mieru/v3 v3.17.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc= +github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= diff --git a/include/registry.go b/include/registry.go index cd4514bd..83490a8c 100644 --- a/include/registry.go +++ b/include/registry.go @@ -31,7 +31,6 @@ import ( "github.com/sagernet/sing-box/protocol/limiter/rate" "github.com/sagernet/sing-box/protocol/limiter/traffic" "github.com/sagernet/sing-box/protocol/mieru" - "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" "github.com/sagernet/sing-box/protocol/parser" @@ -81,6 +80,7 @@ func InboundRegistry() *inbound.Registry { shadowtls.RegisterInbound(registry) vless.RegisterInbound(registry) anytls.RegisterInbound(registry) + mieru.RegisterInbound(registry) bond.RegisterInbound(registry) failover.RegisterInbound(registry) diff --git a/option/mieru.go b/option/mieru.go index fe5a407b..a3d5b7b6 100644 --- a/option/mieru.go +++ b/option/mieru.go @@ -10,4 +10,18 @@ type MieruOutboundOptions struct { UserName string `json:"username,omitempty"` Password string `json:"password,omitempty"` Multiplexing string `json:"multiplexing,omitempty"` + TrafficPattern string `json:"traffic_pattern,omitempty"` +} + +type MieruInboundOptions struct { + ListenOptions + Users []MieruUser `json:"users,omitempty"` + Transport string `json:"transport,omitempty"` + TrafficPattern string `json:"traffic_pattern,omitempty"` + UserHintIsMandatory bool `json:"user_hint_is_mandatory,omitempty"` +} + +type MieruUser struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` } diff --git a/option/sudoku.go b/option/sudoku.go index ceb314b3..ca1b8764 100644 --- a/option/sudoku.go +++ b/option/sudoku.go @@ -1,28 +1,26 @@ package option -import "github.com/sagernet/sing/common/json/badoption" - type SudokuOutboundOptions struct { DialerOptions ServerOptions - Key string `json:"key"` - AEADMethod string `json:"aead_method,omitempty"` - PaddingMin *int `json:"padding_min,omitempty"` - PaddingMax *int `json:"padding_max,omitempty"` - TableType string `json:"table_type,omitempty"` - EnablePureDownlink *bool `json:"enable_pure_downlink,omitempty"` - CustomTable string `json:"custom_table,omitempty"` - CustomTables []string `json:"custom_tables,omitempty"` - HTTPMask *SudokuHTTPMask `json:"http_mask,omitempty"` + Key string `json:"key"` + AEADMethod string `json:"aead_method,omitempty"` + PaddingMin *int `json:"padding_min,omitempty"` + PaddingMax *int `json:"padding_max,omitempty"` + TableType string `json:"table_type,omitempty"` + EnablePureDownlink *bool `json:"enable_pure_downlink,omitempty"` + CustomTable string `json:"custom_table,omitempty"` + CustomTables []string `json:"custom_tables,omitempty"` + HTTPMask *SudokuHTTPMask `json:"http_mask,omitempty"` } type SudokuHTTPMask struct { - Enabled bool `json:"enabled,omitempty"` - Mode string `json:"mode,omitempty"` - Host string `json:"host,omitempty"` - PathRoot string `json:"path_root,omitempty"` - Multiplex string `json:"multiplex,omitempty"` - TLS *SudokuOutboundTLSOptions `json:"tls,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Mode string `json:"mode,omitempty"` + Host string `json:"host,omitempty"` + PathRoot string `json:"path_root,omitempty"` + Multiplex string `json:"multiplex,omitempty"` + OutboundTLSOptionsContainer } type SudokuInboundOptions struct { @@ -41,14 +39,3 @@ type SudokuInboundOptions struct { PathRoot string `json:"path_root,omitempty"` Fallback string `json:"fallback,omitempty"` } - -type SudokuOutboundTLSOptions struct { - Enabled bool `json:"enabled,omitempty"` - Fragment bool `json:"fragment,omitempty"` - FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` - RecordFragment bool `json:"record_fragment,omitempty"` - KernelTx bool `json:"kernel_tx,omitempty"` - KernelRx bool `json:"kernel_rx,omitempty"` -} - - diff --git a/protocol/mieru/inbound.go b/protocol/mieru/inbound.go new file mode 100644 index 00000000..637df5df --- /dev/null +++ b/protocol/mieru/inbound.go @@ -0,0 +1,340 @@ +package mieru + +import ( + "context" + "fmt" + "io" + "net" + "net/netip" + "sync" + + "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" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mierucommon "github.com/enfein/mieru/v3/apis/common" + mieruconstant "github.com/enfein/mieru/v3/apis/constant" + mierumodel "github.com/enfein/mieru/v3/apis/model" + mieruserver "github.com/enfein/mieru/v3/apis/server" + mierutp "github.com/enfein/mieru/v3/apis/trafficpattern" + mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" + "google.golang.org/protobuf/proto" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.MieruInboundOptions](registry, C.TypeMieru, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + server mieruserver.Server + userNames []string + + mu sync.Mutex +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MieruInboundOptions) (adapter.Inbound, error) { + config, userNames, err := buildMieruServerConfig(ctx, options) + if err != nil { + return nil, fmt.Errorf("failed to build mieru server config: %w", err) + } + + s := mieruserver.NewServer() + if err := s.Store(config); err != nil { + return nil, fmt.Errorf("failed to store mieru server config: %w", err) + } + + inboundInstance := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeMieru, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + server: s, + userNames: userNames, + } + inboundInstance.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP, N.NetworkUDP}, + Listen: options.ListenOptions, + }) + + return inboundInstance, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + + h.mu.Lock() + defer h.mu.Unlock() + + if err := h.server.Start(); err != nil { + return fmt.Errorf("failed to start mieru server: %w", err) + } + + h.logger.Info("mieru server is started") + go h.acceptLoop() + return nil +} + +func (h *Inbound) Close() error { + h.mu.Lock() + defer h.mu.Unlock() + + if h.server.IsRunning() { + return h.server.Stop() + } + return nil +} + +func (h *Inbound) acceptLoop() { + for { + conn, request, err := h.server.Accept() + if err != nil { + if !h.server.IsRunning() { + return + } + h.logger.Debug("failed to accept mieru connection: ", err) + continue + } + go h.handleConnection(conn, request) + } +} + +func (h *Inbound) handleConnection(conn net.Conn, request *mierumodel.Request) { + ctx := log.ContextWithNewID(h.ctx) + + // Send fake SOCKS5 response back to proxy client. + resp := &mierumodel.Response{ + Reply: mieruconstant.Socks5ReplySuccess, + BindAddr: mierumodel.AddrSpec{ + IP: net.IPv4zero, + Port: 0, + }, + } + if err := resp.WriteToSocks5(conn); err != nil { + conn.Close() + h.logger.DebugContext(ctx, "failed to write mieru response: ", err) + return + } + + // Build metadata. + var metadata adapter.InboundContext + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + metadata.UDPDisableDomainUnmapping = h.listener.ListenOptions().UDPDisableDomainUnmapping + + // Parse source address. + if remoteAddr := conn.RemoteAddr(); remoteAddr != nil { + metadata.Source = M.SocksaddrFromNet(remoteAddr) + } + + // Parse destination from request. + if request.DstAddr.FQDN != "" { + metadata.Destination = M.Socksaddr{ + Fqdn: request.DstAddr.FQDN, + Port: uint16(request.DstAddr.Port), + } + } else if request.DstAddr.IP != nil { + addr, _ := netip.AddrFromSlice(request.DstAddr.IP) + metadata.Destination = M.Socksaddr{ + Addr: addr.Unmap(), + Port: uint16(request.DstAddr.Port), + } + } + + // Get username from connection. + if userCtx, ok := conn.(mierucommon.UserContext); ok { + metadata.User = userCtx.UserName() + } + + // Handle request. + switch request.Command { + case mieruconstant.Socks5ConnectCmd: + h.logger.InfoContext(ctx, "inbound TCP connection from ", metadata.Source, " to ", metadata.Destination) + if metadata.User != "" { + h.logger.InfoContext(ctx, "[", metadata.User, "] inbound TCP connection") + } + h.router.RouteConnectionEx(ctx, conn, metadata, nil) + case mieruconstant.Socks5UDPAssociateCmd: + h.logger.InfoContext(ctx, "inbound UDP connection from ", metadata.Source, " to ", metadata.Destination) + if metadata.User != "" { + h.logger.InfoContext(ctx, "[", metadata.User, "] inbound UDP connection") + } + h.handleUDP(ctx, conn, metadata) + default: + conn.Close() + h.logger.WarnContext(ctx, "unsupported mieru command: ", request.Command) + } +} + +func (h *Inbound) handleUDP(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) { + pc := mierucommon.NewPacketOverStreamTunnel(conn) + packetConn := &mieruPacketConn{ + PacketConn: pc, + destination: metadata.Destination, + } + h.router.RoutePacketConnectionEx(ctx, packetConn, metadata, nil) +} + +// mieruPacketConn wraps mieru's PacketConn to implement N.PacketConn +type mieruPacketConn struct { + net.PacketConn + destination M.Socksaddr +} + +var _ N.PacketConn = (*mieruPacketConn)(nil) + +// ReadPacket parses the SOCKS5 UDP header and returns the destination address. +func (c *mieruPacketConn) ReadPacket(buffer *buf.Buffer) (destination M.Socksaddr, err error) { + n, _, err := c.PacketConn.ReadFrom(buffer.FreeBytes()) + if err != nil { + return M.Socksaddr{}, err + } + buffer.Truncate(n) + if buffer.Len() < 3 { + return M.Socksaddr{}, io.ErrShortBuffer + } + + // Skip RSV (2 bytes) and FRAG (1 byte). + buffer.Advance(3) + + var addr mierumodel.AddrSpec + if err := addr.ReadFromSocks5(buffer); err != nil { + return M.Socksaddr{}, err + } + if addr.FQDN != "" { + destination = M.Socksaddr{ + Fqdn: addr.FQDN, + Port: uint16(addr.Port), + } + } else if addr.IP != nil { + netAddr, _ := netip.AddrFromSlice(addr.IP) + destination = M.Socksaddr{ + Addr: netAddr.Unmap(), + Port: uint16(addr.Port), + } + } + return destination, nil +} + +// WritePacket writes the SOCKS5 UDP header and the payload. +func (c *mieruPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + header := buf.NewSize(3 + M.MaxSocksaddrLength) + defer header.Release() + + // RSV (2 bytes) + FRAG (1 byte) + common.Must(header.WriteZeroN(3)) + + var addr mierumodel.AddrSpec + if destination.IsFqdn() { + addr.FQDN = destination.Fqdn + } else { + addr.IP = destination.Addr.AsSlice() + } + addr.Port = int(destination.Port) + if err := addr.WriteToSocks5(header); err != nil { + return err + } + + packet := buf.NewSize(header.Len() + buffer.Len()) + defer packet.Release() + common.Must1(packet.Write(header.Bytes())) + common.Must1(packet.Write(buffer.Bytes())) + _, err := c.PacketConn.WriteTo(packet.Bytes(), nil) + return err +} + +func buildMieruServerConfig(_ context.Context, options option.MieruInboundOptions) (*mieruserver.ServerConfig, []string, error) { + if err := validateMieruInboundOptions(options); err != nil { + return nil, nil, fmt.Errorf("failed to validate mieru options: %w", err) + } + + var transportProtocol *mierupb.TransportProtocol + switch options.Transport { + case "TCP": + transportProtocol = mierupb.TransportProtocol_TCP.Enum() + case "UDP": + transportProtocol = mierupb.TransportProtocol_UDP.Enum() + } + + if options.ListenOptions.ListenPort == 0 { + return nil, nil, E.New("listen_port must be set") + } + portBindings := []*mierupb.PortBinding{ + { + Port: proto.Int32(int32(options.ListenOptions.ListenPort)), + Protocol: transportProtocol, + }, + } + + var users []*mierupb.User + var userNames []string + for _, user := range options.Users { + users = append(users, &mierupb.User{ + Name: proto.String(user.Name), + Password: proto.String(user.Password), + }) + userNames = append(userNames, user.Name) + } + var trafficPattern *mierupb.TrafficPattern + trafficPattern, _ = mierutp.Decode(options.TrafficPattern) + var advancedSettings *mierupb.ServerAdvancedSettings + if options.UserHintIsMandatory { + advancedSettings = &mierupb.ServerAdvancedSettings{ + UserHintIsMandatory: proto.Bool(true), + } + } + return &mieruserver.ServerConfig{ + Config: &mierupb.ServerConfig{ + PortBindings: portBindings, + Users: users, + TrafficPattern: trafficPattern, + AdvancedSettings: advancedSettings, + }, + }, userNames, nil +} + +func validateMieruInboundOptions(options option.MieruInboundOptions) error { + if options.Transport != "TCP" && options.Transport != "UDP" { + return E.New("transport must be TCP or UDP") + } + if len(options.Users) == 0 { + return E.New("users is empty") + } + for _, user := range options.Users { + if user.Name == "" { + return E.New("username is empty") + } + if user.Password == "" { + return E.New("password is empty") + } + } + if options.TrafficPattern != "" { + trafficPattern, err := mierutp.Decode(options.TrafficPattern) + if err != nil { + return fmt.Errorf("failed to decode traffic pattern %q: %w", options.TrafficPattern, err) + } + if err := mierutp.Validate(trafficPattern); err != nil { + return fmt.Errorf("invalid traffic pattern %q: %w", options.TrafficPattern, err) + } + } + return nil +} diff --git a/protocol/mieru/outbound.go b/protocol/mieru/outbound.go index d3804b5e..d04826fd 100644 --- a/protocol/mieru/outbound.go +++ b/protocol/mieru/outbound.go @@ -20,6 +20,7 @@ import ( mieruclient "github.com/enfein/mieru/v3/apis/client" mierucommon "github.com/enfein/mieru/v3/apis/common" mierumodel "github.com/enfein/mieru/v3/apis/model" + mierutp "github.com/enfein/mieru/v3/apis/trafficpattern" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" "google.golang.org/protobuf/proto" ) @@ -36,7 +37,7 @@ func RegisterOutbound(registry *outbound.Registry) { } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MieruOutboundOptions) (adapter.Outbound, error) { - outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + outboundDialer, err := dialer.New(ctx, options.DialerOptions, M.IsDomainName(options.Server)) if err != nil { return nil, err } @@ -123,7 +124,15 @@ func (md mieruDialer) DialContext(ctx context.Context, network, address string) return md.dialer.DialContext(ctx, network, addr) } -var _ mierucommon.Dialer = (*mieruDialer)(nil) +func (md mieruDialer) ListenPacket(ctx context.Context, network, laddr, raddr string) (net.PacketConn, error) { + addr := M.ParseSocksaddr(raddr) + return md.dialer.ListenPacket(ctx, addr) +} + +var ( + _ mierucommon.Dialer = (*mieruDialer)(nil) + _ mierucommon.PacketDialer = (*mieruDialer)(nil) +) // streamer converts a net.PacketConn to a net.Conn. type streamer struct { @@ -161,7 +170,13 @@ func buildMieruClientConfig(options option.MieruOutboundOptions, dialer mieruDia return nil, fmt.Errorf("failed to validate mieru options: %w", err) } - transportProtocol := mierupb.TransportProtocol_TCP.Enum() + var transportProtocol *mierupb.TransportProtocol + switch options.Transport { + case "TCP": + transportProtocol = mierupb.TransportProtocol_TCP.Enum() + case "UDP": + transportProtocol = mierupb.TransportProtocol_UDP.Enum() + } server := &mierupb.ServerEndpoint{} if options.ServerPort != 0 { server.PortBindings = append(server.PortBindings, &mierupb.PortBinding{ @@ -189,13 +204,21 @@ func buildMieruClientConfig(options option.MieruOutboundOptions, dialer mieruDia }, Servers: []*mierupb.ServerEndpoint{server}, }, - Dialer: dialer, + Dialer: dialer, + PacketDialer: dialer, + DNSConfig: &mierucommon.ClientDNSConfig{ + BypassDialerDNS: true, + }, } if multiplexing, ok := mierupb.MultiplexingLevel_value[options.Multiplexing]; ok { config.Profile.Multiplexing = &mierupb.MultiplexingConfig{ Level: mierupb.MultiplexingLevel(multiplexing).Enum(), } } + if options.TrafficPattern != "" { + trafficPattern, _ := mierutp.Decode(options.TrafficPattern) + config.Profile.TrafficPattern = trafficPattern + } return config, nil } @@ -221,8 +244,8 @@ func validateMieruOptions(options option.MieruOutboundOptions) error { return fmt.Errorf("begin port must be less than or equal to end port") } } - if options.Transport != "TCP" { - return fmt.Errorf("transport must be TCP") + if options.Transport != "TCP" && options.Transport != "UDP" { + return fmt.Errorf("transport must be TCP or UDP") } if options.UserName == "" { return fmt.Errorf("username is empty") @@ -235,6 +258,15 @@ func validateMieruOptions(options option.MieruOutboundOptions) error { return fmt.Errorf("invalid multiplexing level: %s", options.Multiplexing) } } + if options.TrafficPattern != "" { + trafficPattern, err := mierutp.Decode(options.TrafficPattern) + if err != nil { + return fmt.Errorf("failed to decode traffic pattern %q: %w", options.TrafficPattern, err) + } + if err := mierutp.Validate(trafficPattern); err != nil { + return fmt.Errorf("invalid traffic pattern %q: %w", options.TrafficPattern, err) + } + } return nil } diff --git a/protocol/sudoku/inbound.go b/protocol/sudoku/inbound.go index 89090e6c..bc68c680 100644 --- a/protocol/sudoku/inbound.go +++ b/protocol/sudoku/inbound.go @@ -67,27 +67,27 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo protoConf.AEADMethod = options.AEADMethod } - in := &Inbound{ + inbound := &Inbound{ Adapter: inbound.NewAdapter(C.TypeSudoku, tag), router: router, logger: logger, protoConf: protoConf, fallback: strings.TrimSpace(options.Fallback), } - if in.fallback != "" { - in.tunnelSrv = sudoku.NewHTTPMaskTunnelServerWithFallback(&in.protoConf) + if inbound.fallback != "" { + inbound.tunnelSrv = sudoku.NewHTTPMaskTunnelServerWithFallback(&inbound.protoConf) } else { - in.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&in.protoConf) + inbound.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&inbound.protoConf) } - in.listener = listener.New(listener.Options{ + inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, Network: []string{N.NetworkTCP}, Listen: options.ListenOptions, - ConnectionHandler: in, + ConnectionHandler: inbound, }) - return in, nil + return inbound, nil } func (h *Inbound) Start(stage adapter.StartStage) error { @@ -173,6 +173,7 @@ func (h *Inbound) routeTCP(ctx context.Context, conn net.Conn, target string, me } func (h *Inbound) handleUoT(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + h.logger.InfoContext(ctx, "inbound packet connection") packetConn := sudoku.NewUoTPacketConn(conn) h.router.RoutePacketConnectionEx(ctx, bufio.NewPacketConn(packetConn), metadata, onClose) } diff --git a/protocol/sudoku/outbound.go b/protocol/sudoku/outbound.go index 03902045..bdaa163d 100644 --- a/protocol/sudoku/outbound.go +++ b/protocol/sudoku/outbound.go @@ -2,10 +2,8 @@ package sudoku import ( "context" - "fmt" "net" "strings" - "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" @@ -15,7 +13,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/transport/sudoku" - "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" @@ -31,16 +28,8 @@ func RegisterOutbound(registry *outbound.Registry) { type Outbound struct { outbound.Adapter logger logger.ContextLogger - dialer N.Dialer + client *sudoku.Client tlsConfig tls.Config - baseConf sudoku.ProtocolConfig - - muxMu sync.Mutex - muxClient *sudoku.MultiplexClient - - httpMaskMu sync.Mutex - httpMaskClient *httpmask.TunnelClient - httpMaskKey string } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SudokuOutboundOptions) (adapter.Outbound, error) { @@ -105,33 +94,31 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL baseConf.Tables = tables } - out := &Outbound{ - Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSudoku, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), - logger: logger, - dialer: outboundDialer, - baseConf: baseConf, - } + var tlsConfig tls.Config if hm := options.HTTPMask; !disableHTTPMask && hm != nil && hm.TLS != nil && hm.TLS.Enabled { - tlsOptions := option.OutboundTLSOptions{ - Enabled: true, - ServerName: options.Server, - Fragment: hm.TLS.Fragment, - FragmentFallbackDelay: hm.TLS.FragmentFallbackDelay, - RecordFragment: hm.TLS.RecordFragment, - KernelTx: hm.TLS.KernelTx, - KernelRx: hm.TLS.KernelRx, - } - out.tlsConfig, err = tls.NewClientWithOptions(tls.ClientOptions{ + tlsConfig, err = tls.NewClientWithOptions(tls.ClientOptions{ Context: ctx, Logger: logger, ServerAddress: options.Server, - Options: tlsOptions, + Options: *hm.TLS, }) if err != nil { return nil, err } } - return out, nil + + client := sudoku.NewClient(sudoku.ClientOptions{ + Dialer: outboundDialer, + TLSConfig: tlsConfig, + Config: baseConf, + }) + + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSudoku, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + logger: logger, + client: client, + tlsConfig: tlsConfig, + }, nil } func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { @@ -144,35 +131,7 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination ctx, metadata := adapter.ExtendContext(ctx) metadata.Outbound = h.Tag() metadata.Destination = destination - - cfg := h.baseConf - cfg.TargetAddress = destination.String() - - muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) - if muxMode == "on" && !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { - stream, err := h.dialMultiplex(ctx, cfg.TargetAddress) - if err == nil { - return stream, nil - } - return nil, err - } - - c, err := h.dialAndHandshake(ctx, &cfg) - if err != nil { - return nil, err - } - - addrBuf, err := sudoku.EncodeAddress(cfg.TargetAddress) - if err != nil { - c.Close() - return nil, E.Cause(err, "encode target address") - } - if err = sudoku.WriteKIPMessage(c, sudoku.KIPTypeOpenTCP, addrBuf); err != nil { - c.Close() - return nil, E.Cause(err, "send target address") - } - - return c, nil + return h.client.DialContext(ctx, network, destination) } func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -180,222 +139,18 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n ctx, metadata := adapter.ExtendContext(ctx) metadata.Outbound = h.Tag() metadata.Destination = destination - - cfg := h.baseConf - cfg.TargetAddress = destination.String() - - c, err := h.dialAndHandshake(ctx, &cfg) + conn, err := h.client.DialContext(ctx, N.NetworkUDP, destination) if err != nil { return nil, err } - - if err = sudoku.WriteKIPMessage(c, sudoku.KIPTypeStartUoT, nil); err != nil { - c.Close() - return nil, E.Cause(err, "start uot") - } - - return bufio.NewBindPacketConn(sudoku.NewUoTPacketConn(c), destination), nil + return bufio.NewBindPacketConn(sudoku.NewUoTPacketConn(conn), destination), nil } func (h *Outbound) Close() error { - h.resetMuxClient() - h.resetHTTPMaskClient() + h.client.Close() return common.Close(h.tlsConfig) } func (h *Outbound) InterfaceUpdated() { - h.resetMuxClient() - h.resetHTTPMaskClient() -} - -func (h *Outbound) dialAndHandshake(ctx context.Context, cfg *sudoku.ProtocolConfig) (net.Conn, error) { - handshakeCfg := *cfg - if !handshakeCfg.DisableHTTPMask && httpTunnelModeEnabled(handshakeCfg.HTTPMaskMode) { - handshakeCfg.DisableHTTPMask = true - } - - upgrade := func(raw net.Conn) (net.Conn, error) { - return sudoku.ClientHandshake(raw, &handshakeCfg) - } - - var c net.Conn - var err error - var handshakeDone bool - - if !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { - muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) - if muxMode == "auto" && strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) != "ws" { - if client, cerr := h.getOrCreateHTTPMaskClient(cfg); cerr == nil && client != nil { - c, err = client.DialTunnel(ctx, httpmask.TunnelDialOptions{ - Mode: cfg.HTTPMaskMode, - TLSConfig: h.httpMaskTLSConfig(), - HostOverride: cfg.HTTPMaskHost, - PathRoot: cfg.HTTPMaskPathRoot, - AuthKey: sudoku.ClientAEADSeed(cfg.Key), - Upgrade: upgrade, - Multiplex: cfg.HTTPMaskMultiplex, - DialContext: h.dialRaw, - }) - if err != nil { - h.resetHTTPMaskClient() - } - } - } - if c == nil && err == nil { - c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, h.dialRaw, upgrade) - } - if err == nil && c != nil { - handshakeDone = true - } - } - if c == nil && err == nil { - c, err = h.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(cfg.ServerAddress)) - } - if err != nil { - return nil, E.Cause(err, "connect to ", cfg.ServerAddress) - } - - if !handshakeDone { - c, err = sudoku.ClientHandshake(c, &handshakeCfg) - if err != nil { - common.Close(c) - return nil, err - } - } - - return c, nil -} - -func (h *Outbound) dialRaw(ctx context.Context, network, addr string) (net.Conn, error) { - return h.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) -} - -func (h *Outbound) httpMaskTLSConfig() httpmask.TLSClientConfig { - if h.tlsConfig == nil { - return nil - } - return tlsConfigAdapter{h.tlsConfig} -} - -type tlsConfigAdapter struct { - config tls.Config -} - -func (a tlsConfigAdapter) Client(conn net.Conn) (net.Conn, error) { - return a.config.Client(conn) -} - -func (h *Outbound) dialMultiplex(ctx context.Context, targetAddress string) (net.Conn, error) { - for attempt := 0; attempt < 2; attempt++ { - client, err := h.getOrCreateMuxClient(ctx) - if err != nil { - return nil, err - } - stream, err := client.Dial(ctx, targetAddress) - if err != nil { - h.resetMuxClient() - continue - } - return stream, nil - } - return nil, fmt.Errorf("multiplex open stream failed") -} - -func (h *Outbound) getOrCreateMuxClient(ctx context.Context) (*sudoku.MultiplexClient, error) { - h.muxMu.Lock() - defer h.muxMu.Unlock() - - if h.muxClient != nil && !h.muxClient.IsClosed() { - return h.muxClient, nil - } - - baseCfg := h.baseConf - baseConn, err := h.dialAndHandshake(ctx, &baseCfg) - if err != nil { - return nil, err - } - - client, err := sudoku.StartMultiplexClient(baseConn) - if err != nil { - baseConn.Close() - return nil, err - } - h.muxClient = client - return client, nil -} - -func (h *Outbound) resetMuxClient() { - h.muxMu.Lock() - defer h.muxMu.Unlock() - if h.muxClient != nil { - h.muxClient.Close() - h.muxClient = nil - } -} - -func (h *Outbound) getOrCreateHTTPMaskClient(cfg *sudoku.ProtocolConfig) (*httpmask.TunnelClient, error) { - key := cfg.ServerAddress + "|" + fmt.Sprint(h.tlsConfig != nil) + "|" + strings.TrimSpace(cfg.HTTPMaskHost) - - h.httpMaskMu.Lock() - if h.httpMaskClient != nil && h.httpMaskKey == key { - client := h.httpMaskClient - h.httpMaskMu.Unlock() - return client, nil - } - h.httpMaskMu.Unlock() - - client, err := httpmask.NewTunnelClient(cfg.ServerAddress, httpmask.TunnelClientOptions{ - TLSConfig: h.httpMaskTLSConfig(), - HostOverride: cfg.HTTPMaskHost, - DialContext: h.dialRaw, - MaxIdleConns: 32, - }) - if err != nil { - return nil, err - } - - h.httpMaskMu.Lock() - defer h.httpMaskMu.Unlock() - if h.httpMaskClient != nil && h.httpMaskKey == key { - client.CloseIdleConnections() - return h.httpMaskClient, nil - } - if h.httpMaskClient != nil { - h.httpMaskClient.CloseIdleConnections() - } - h.httpMaskClient = client - h.httpMaskKey = key - return client, nil -} - -func (h *Outbound) resetHTTPMaskClient() { - h.httpMaskMu.Lock() - defer h.httpMaskMu.Unlock() - if h.httpMaskClient != nil { - h.httpMaskClient.CloseIdleConnections() - h.httpMaskClient = nil - h.httpMaskKey = "" - } -} - -func normalizeHTTPMaskMultiplex(mode string) string { - switch strings.ToLower(strings.TrimSpace(mode)) { - case "", "off": - return "off" - case "auto": - return "auto" - case "on": - return "on" - default: - return "off" - } -} - -func httpTunnelModeEnabled(mode string) bool { - switch strings.ToLower(strings.TrimSpace(mode)) { - case "stream", "poll", "auto", "ws": - return true - default: - return false - } + h.client.Close() } diff --git a/protocol/trusttunnel/inbound.go b/protocol/trusttunnel/inbound.go index 4b937b2f..3b955ed1 100644 --- a/protocol/trusttunnel/inbound.go +++ b/protocol/trusttunnel/inbound.go @@ -138,7 +138,7 @@ func (h *Inbound) Close() error { return common.Close( h.listener, common.PtrOrNil(h.httpServer), - h.quicService, + common.PtrOrNil(h.quicService), h.tlsConfig, ) } diff --git a/route/conn.go b/route/conn.go index 58de9320..34de251d 100644 --- a/route/conn.go +++ b/route/conn.go @@ -279,7 +279,7 @@ func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn, if !direction { if err == nil { m.logger.DebugContext(ctx, "connection upload finished") - } else if !E.IsClosedOrCanceled(err) && !strings.Contains(err.Error(), "NO_ERROR") { + } else if !E.IsClosedOrCanceled(err) && !strings.Contains(err.Error(), "NO_ERROR") && !strings.Contains(err.Error(), "CANCEL") && !strings.Contains(err.Error(), "body closed") { m.logger.ErrorContext(ctx, "connection upload closed: ", err) } else { m.logger.TraceContext(ctx, "connection upload closed") diff --git a/test/go.mod b/test/go.mod index 14a4d20a..4fd222b9 100644 --- a/test/go.mod +++ b/test/go.mod @@ -75,7 +75,7 @@ require ( github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/enfein/mieru/v3 v3.17.1 // indirect + github.com/enfein/mieru/v3 v3.33.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/test/go.sum b/test/go.sum index 9866e46d..0ec1be55 100644 --- a/test/go.sum +++ b/test/go.sum @@ -88,8 +88,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBHY= -github.com/enfein/mieru/v3 v3.17.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc= +github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= diff --git a/transport/sudoku/client.go b/transport/sudoku/client.go new file mode 100644 index 00000000..b4518212 --- /dev/null +++ b/transport/sudoku/client.go @@ -0,0 +1,293 @@ +package sudoku + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type ClientOptions struct { + Dialer N.Dialer + TLSConfig tls.Config + Config ProtocolConfig +} + +type Client struct { + dialer N.Dialer + tlsConfig tls.Config + baseConf ProtocolConfig + + muxMu sync.Mutex + muxClient *MultiplexClient + + httpMaskMu sync.Mutex + httpMaskClient *httpmask.TunnelClient + httpMaskKey string +} + +func NewClient(options ClientOptions) *Client { + return &Client{ + dialer: options.Dialer, + tlsConfig: options.TLSConfig, + baseConf: options.Config, + } +} + +func (c *Client) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + cfg := c.baseConf + cfg.TargetAddress = destination.String() + + muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) + if muxMode == "on" && !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { + stream, err := c.dialMultiplex(ctx, cfg.TargetAddress) + if err == nil { + return stream, nil + } + return nil, err + } + + conn, err := c.dialAndHandshake(ctx, &cfg) + if err != nil { + return nil, err + } + + switch N.NetworkName(network) { + case N.NetworkUDP: + if err = WriteKIPMessage(conn, KIPTypeStartUoT, nil); err != nil { + conn.Close() + return nil, E.Cause(err, "start uot") + } + return conn, nil + default: + addrBuf, err := EncodeAddress(cfg.TargetAddress) + if err != nil { + conn.Close() + return nil, E.Cause(err, "encode target address") + } + if err = WriteKIPMessage(conn, KIPTypeOpenTCP, addrBuf); err != nil { + conn.Close() + return nil, E.Cause(err, "send target address") + } + return conn, nil + } +} + +func (c *Client) Close() { + c.resetMuxClient() + c.resetHTTPMaskClient() +} + +func (c *Client) dialAndHandshake(ctx context.Context, cfg *ProtocolConfig) (net.Conn, error) { + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask && httpTunnelModeEnabled(handshakeCfg.HTTPMaskMode) { + handshakeCfg.DisableHTTPMask = true + } + + upgrade := func(raw net.Conn) (net.Conn, error) { + return ClientHandshake(raw, &handshakeCfg) + } + + var conn net.Conn + var err error + var handshakeDone bool + + if !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { + muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) + if muxMode == "auto" && strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) != "ws" { + if client, cerr := c.getOrCreateHTTPMaskClient(cfg); cerr == nil && client != nil { + conn, err = client.DialTunnel(ctx, httpmask.TunnelDialOptions{ + Mode: cfg.HTTPMaskMode, + TLSConfig: c.httpMaskTLSConfig(), + HostOverride: cfg.HTTPMaskHost, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: ClientAEADSeed(cfg.Key), + Upgrade: upgrade, + Multiplex: cfg.HTTPMaskMultiplex, + DialContext: c.dialRaw, + }) + if err != nil { + c.resetHTTPMaskClient() + } + } + } + if conn == nil && err == nil { + conn, err = c.dialHTTPMaskTunnel(ctx, cfg, upgrade) + } + if err == nil && conn != nil { + handshakeDone = true + } + } + if conn == nil && err == nil { + conn, err = c.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(cfg.ServerAddress)) + } + if err != nil { + return nil, E.Cause(err, "connect to ", cfg.ServerAddress) + } + + if !handshakeDone { + conn, err = ClientHandshake(conn, &handshakeCfg) + if err != nil { + common.Close(conn) + return nil, err + } + } + + return conn, nil +} + +func (c *Client) dialRaw(ctx context.Context, network, addr string) (net.Conn, error) { + return c.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) +} + +func (c *Client) dialHTTPMaskTunnel(ctx context.Context, cfg *ProtocolConfig, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) { + var earlyHandshake *httpmask.ClientEarlyHandshake + var err error + if upgrade != nil { + earlyHandshake, err = newClientHTTPMaskEarlyHandshake(cfg) + if err != nil { + return nil, err + } + } + return httpmask.DialTunnel(ctx, cfg.ServerAddress, httpmask.TunnelDialOptions{ + Mode: cfg.HTTPMaskMode, + TLSConfig: c.httpMaskTLSConfig(), + HostOverride: cfg.HTTPMaskHost, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: ClientAEADSeed(cfg.Key), + EarlyHandshake: earlyHandshake, + Upgrade: upgrade, + Multiplex: cfg.HTTPMaskMultiplex, + DialContext: c.dialRaw, + }) +} + +func (c *Client) httpMaskTLSConfig() tls.Config { + if c.tlsConfig == nil { + return nil + } + return c.tlsConfig +} + +func (c *Client) dialMultiplex(ctx context.Context, targetAddress string) (net.Conn, error) { + for attempt := 0; attempt < 2; attempt++ { + client, err := c.getOrCreateMuxClient(ctx) + if err != nil { + return nil, err + } + stream, err := client.Dial(ctx, targetAddress) + if err != nil { + c.resetMuxClient() + continue + } + return stream, nil + } + return nil, fmt.Errorf("multiplex open stream failed") +} + +func (c *Client) getOrCreateMuxClient(ctx context.Context) (*MultiplexClient, error) { + c.muxMu.Lock() + defer c.muxMu.Unlock() + + if c.muxClient != nil && !c.muxClient.IsClosed() { + return c.muxClient, nil + } + + baseCfg := c.baseConf + baseConn, err := c.dialAndHandshake(ctx, &baseCfg) + if err != nil { + return nil, err + } + + client, err := StartMultiplexClient(baseConn) + if err != nil { + baseConn.Close() + return nil, err + } + c.muxClient = client + return client, nil +} + +func (c *Client) resetMuxClient() { + c.muxMu.Lock() + defer c.muxMu.Unlock() + if c.muxClient != nil { + c.muxClient.Close() + c.muxClient = nil + } +} + +func (c *Client) getOrCreateHTTPMaskClient(cfg *ProtocolConfig) (*httpmask.TunnelClient, error) { + key := cfg.ServerAddress + "|" + fmt.Sprint(c.tlsConfig != nil) + "|" + strings.TrimSpace(cfg.HTTPMaskHost) + + c.httpMaskMu.Lock() + if c.httpMaskClient != nil && c.httpMaskKey == key { + client := c.httpMaskClient + c.httpMaskMu.Unlock() + return client, nil + } + c.httpMaskMu.Unlock() + + client, err := httpmask.NewTunnelClient(cfg.ServerAddress, httpmask.TunnelClientOptions{ + TLSConfig: c.httpMaskTLSConfig(), + HostOverride: cfg.HTTPMaskHost, + DialContext: c.dialRaw, + MaxIdleConns: 32, + }) + if err != nil { + return nil, err + } + + c.httpMaskMu.Lock() + defer c.httpMaskMu.Unlock() + if c.httpMaskClient != nil && c.httpMaskKey == key { + client.CloseIdleConnections() + return c.httpMaskClient, nil + } + if c.httpMaskClient != nil { + c.httpMaskClient.CloseIdleConnections() + } + c.httpMaskClient = client + c.httpMaskKey = key + return client, nil +} + +func (c *Client) resetHTTPMaskClient() { + c.httpMaskMu.Lock() + defer c.httpMaskMu.Unlock() + if c.httpMaskClient != nil { + c.httpMaskClient.CloseIdleConnections() + c.httpMaskClient = nil + c.httpMaskKey = "" + } +} + +func normalizeHTTPMaskMultiplex(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "off": + return "off" + case "auto": + return "auto" + case "on": + return "on" + default: + return "off" + } +} + +func httpTunnelModeEnabled(mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "stream", "poll", "auto", "ws": + return true + default: + return false + } +} diff --git a/transport/sudoku/obfs/httpmask/tunnel.go b/transport/sudoku/obfs/httpmask/tunnel.go index 578f0f43..46d7c7a2 100644 --- a/transport/sudoku/obfs/httpmask/tunnel.go +++ b/transport/sudoku/obfs/httpmask/tunnel.go @@ -22,11 +22,9 @@ import ( "net/http" "net/http/httputil" -) -type TLSClientConfig interface { - Client(conn net.Conn) (net.Conn, error) -} + "github.com/sagernet/sing-box/common/tls" +) type TunnelMode string @@ -66,7 +64,7 @@ const ( type TunnelDialOptions struct { Mode string - TLSConfig TLSClientConfig + TLSConfig tls.Config HostOverride string // PathRoot is an optional first-level path prefix for all HTTP tunnel endpoints. // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... @@ -91,7 +89,7 @@ type TunnelDialOptions struct { } type TunnelClientOptions struct { - TLSConfig TLSClientConfig + TLSConfig tls.Config HostOverride string DialContext func(ctx context.Context, network, addr string) (net.Conn, error) MaxIdleConns int @@ -244,7 +242,7 @@ type httpClientTarget struct { headerHost string } -func buildHTTPTransport(serverAddress string, tlsEnabled bool, tlsConfig TLSClientConfig, hostOverride string, dialContext func(ctx context.Context, network, addr string) (net.Conn, error), maxIdleConns int) (*http.Transport, httpClientTarget, error) { +func buildHTTPTransport(serverAddress string, tlsEnabled bool, tlsConfig tls.Config, hostOverride string, dialContext func(ctx context.Context, network, addr string) (net.Conn, error), maxIdleConns int) (*http.Transport, httpClientTarget, error) { if dialContext == nil { panic("httpmask: DialContext is nil") } diff --git a/transport/trusttunnel/quic.go b/transport/trusttunnel/quic.go index 41f1c394..5d0f85d1 100644 --- a/transport/trusttunnel/quic.go +++ b/transport/trusttunnel/quic.go @@ -114,6 +114,9 @@ func (s *QUICService) Start(ctx context.Context, udpConn net.PacketConn, tlsConf return ctx }, } + if err := qtls.ConfigureHTTP3(tlsConfig); err != nil { + return err + } quicListener, err := qtls.ListenEarly(udpConn, tlsConfig, &quic.Config{ MaxIdleTimeout: DefaultSessionTimeout * 2, MaxIncomingStreams: 1 << 60,