diff --git a/adapter/outbound/sudoku.go b/adapter/outbound/sudoku.go index 8f952dd6..0f962e1c 100644 --- a/adapter/outbound/sudoku.go +++ b/adapter/outbound/sudoku.go @@ -43,6 +43,7 @@ type SudokuOption struct { HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" HTTPMaskTLS bool `proxy:"http-mask-tls,omitempty"` // only for http-mask-mode stream/poll/auto HTTPMaskHost string `proxy:"http-mask-host,omitempty"` // optional Host/SNI override (domain or domain:port) + PathRoot string `proxy:"path-root,omitempty"` // optional first-level path prefix for HTTP tunnel endpoints HTTPMaskMultiplex string `proxy:"http-mask-multiplex,omitempty"` // "off" (default), "auto" (reuse h1/h2), "on" (single tunnel, multi-target) CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty @@ -183,6 +184,7 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { HTTPMaskMode: defaultConf.HTTPMaskMode, HTTPMaskTLSEnabled: option.HTTPMaskTLS, HTTPMaskHost: option.HTTPMaskHost, + HTTPMaskPathRoot: strings.TrimSpace(option.PathRoot), HTTPMaskMultiplex: defaultConf.HTTPMaskMultiplex, } if option.HTTPMaskMode != "" { @@ -257,7 +259,19 @@ func (s *Sudoku) dialAndHandshake(ctx context.Context, cfg *sudoku.ProtocolConfi return nil, fmt.Errorf("config is required") } - var c net.Conn + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask && httpTunnelModeEnabled(handshakeCfg.HTTPMaskMode) { + handshakeCfg.DisableHTTPMask = true + } + + upgrade := func(raw net.Conn) (net.Conn, error) { + return sudoku.ClientHandshakeWithOptions(raw, &handshakeCfg, sudoku.ClientHandshakeOptions{}) + } + + var ( + c net.Conn + handshakeDone bool + ) if !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) switch muxMode { @@ -266,9 +280,12 @@ func (s *Sudoku) dialAndHandshake(ctx context.Context, cfg *sudoku.ProtocolConfi if errX != nil { return nil, errX } - c, err = client.Dial(ctx) + c, err = client.Dial(ctx, upgrade) default: - c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext) + c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext, upgrade) + } + if err == nil && c != nil { + handshakeDone = true } } if c == nil && err == nil { @@ -285,14 +302,11 @@ func (s *Sudoku) dialAndHandshake(ctx context.Context, cfg *sudoku.ProtocolConfi defer done(&err) } - handshakeCfg := *cfg - if !handshakeCfg.DisableHTTPMask && httpTunnelModeEnabled(handshakeCfg.HTTPMaskMode) { - handshakeCfg.DisableHTTPMask = true - } - - c, err = sudoku.ClientHandshakeWithOptions(c, &handshakeCfg, sudoku.ClientHandshakeOptions{}) - if err != nil { - return nil, err + if !handshakeDone { + c, err = sudoku.ClientHandshakeWithOptions(c, &handshakeCfg, sudoku.ClientHandshakeOptions{}) + if err != nil { + return nil, err + } } return c, nil diff --git a/docs/config.yaml b/docs/config.yaml index d46f3024..2722f55a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1072,6 +1072,7 @@ proxies: # socks5 # http-mask-mode: legacy # 可选:legacy(默认)、stream、poll、auto;stream/poll/auto 支持走 CDN/反代 # http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto 时生效;true 强制 https;false 强制 http(不会根据端口自动推断) # http-mask-host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 http-mask-mode 为 stream/poll/auto 时生效 + # path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload # http-mask-multiplex: off # 可选:off(默认)、auto(复用 h1.1 keep-alive / h2 连接,减少每次建链 RTT)、on(单条隧道内多路复用多个目标连接;仅在 http-mask-mode=stream/poll/auto 生效) enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none) @@ -1621,6 +1622,7 @@ listeners: enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none) disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false) # http-mask-mode: legacy # 可选:legacy(默认)、stream、poll、auto;stream/poll/auto 支持走 CDN/反代 + # path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload diff --git a/listener/config/sudoku.go b/listener/config/sudoku.go index 118e252c..e22f2418 100644 --- a/listener/config/sudoku.go +++ b/listener/config/sudoku.go @@ -22,6 +22,7 @@ type SudokuServer struct { CustomTables []string `json:"custom-tables,omitempty"` DisableHTTPMask bool `json:"disable-http-mask,omitempty"` HTTPMaskMode string `json:"http-mask-mode,omitempty"` + PathRoot string `json:"path-root,omitempty"` // mihomo private extension (not the part of standard Sudoku protocol) MuxOption sing.MuxOption `json:"mux-option,omitempty"` diff --git a/listener/inbound/sudoku.go b/listener/inbound/sudoku.go index fc37cb79..04b47de0 100644 --- a/listener/inbound/sudoku.go +++ b/listener/inbound/sudoku.go @@ -24,6 +24,7 @@ type SudokuOption struct { CustomTables []string `inbound:"custom-tables,omitempty"` DisableHTTPMask bool `inbound:"disable-http-mask,omitempty"` HTTPMaskMode string `inbound:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" + PathRoot string `inbound:"path-root,omitempty"` // optional first-level path prefix for HTTP tunnel endpoints // mihomo private extension (not the part of standard Sudoku protocol) MuxOption MuxOption `inbound:"mux-option,omitempty"` @@ -63,6 +64,7 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) { CustomTables: options.CustomTables, DisableHTTPMask: options.DisableHTTPMask, HTTPMaskMode: options.HTTPMaskMode, + PathRoot: strings.TrimSpace(options.PathRoot), } serverConf.MuxOption = options.MuxOption.Build() diff --git a/listener/sudoku/server.go b/listener/sudoku/server.go index d0d3b404..ca2cbe0e 100644 --- a/listener/sudoku/server.go +++ b/listener/sudoku/server.go @@ -229,6 +229,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) HandshakeTimeoutSeconds: handshakeTimeout, DisableHTTPMask: config.DisableHTTPMask, HTTPMaskMode: config.HTTPMaskMode, + HTTPMaskPathRoot: strings.TrimSpace(config.PathRoot), } if len(tables) == 1 { protoConf.Table = tables[0] diff --git a/transport/sudoku/config.go b/transport/sudoku/config.go index 27649fbc..d13eab43 100644 --- a/transport/sudoku/config.go +++ b/transport/sudoku/config.go @@ -58,6 +58,10 @@ type ProtocolConfig struct { // HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side). HTTPMaskHost string + // HTTPMaskPathRoot optionally prefixes all HTTP mask paths with a first-level segment. + // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... + HTTPMaskPathRoot string + // HTTPMaskMultiplex controls multiplex behavior when HTTPMask tunnel modes are enabled: // - "off": disable reuse; each Dial establishes its own HTTPMask tunnel // - "auto": reuse underlying HTTP connections across multiple tunnel dials (HTTP/1.1 keep-alive / HTTP/2) @@ -109,6 +113,23 @@ func (c *ProtocolConfig) Validate() error { return fmt.Errorf("invalid http-mask-mode: %s, must be one of: legacy, stream, poll, auto", c.HTTPMaskMode) } + if v := strings.TrimSpace(c.HTTPMaskPathRoot); v != "" { + if strings.Contains(v, "/") { + return fmt.Errorf("invalid http-mask-path-root: must be a single path segment") + } + for i := 0; i < len(v); i++ { + ch := v[i] + switch { + case ch >= 'a' && ch <= 'z': + case ch >= 'A' && ch <= 'Z': + case ch >= '0' && ch <= '9': + case ch == '_' || ch == '-': + default: + return fmt.Errorf("invalid http-mask-path-root: contains invalid character %q", ch) + } + } + } + switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMultiplex)) { case "", "off", "auto", "on": default: diff --git a/transport/sudoku/handshake.go b/transport/sudoku/handshake.go index 6963add5..3e75ac3c 100644 --- a/transport/sudoku/handshake.go +++ b/transport/sudoku/handshake.go @@ -2,7 +2,6 @@ package sudoku import ( "bufio" - "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -153,14 +152,17 @@ func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, func buildHandshakePayload(key string) [16]byte { var payload [16]byte binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Unix())) - // Hash the decoded HEX bytes of the key, not the HEX string itself. - // This ensures the user hash is computed on the actual key bytes. - keyBytes, err := hex.DecodeString(key) - if err != nil { - // Fallback: if key is not valid HEX (e.g., a UUID or plain string), hash the string bytes - keyBytes = []byte(key) + + // Align with upstream: only decode hex bytes when this key is an ED25519 key material. + // For plain UUID/strings (even if they look like hex), hash the string bytes as-is. + src := []byte(key) + if _, err := crypto.RecoverPublicKey(key); err == nil { + if keyBytes, decErr := hex.DecodeString(key); decErr == nil && len(keyBytes) > 0 { + src = keyBytes + } } - hash := sha256.Sum256(keyBytes) + + hash := sha256.Sum256(src) copy(payload[8:], hash[:8]) return payload } @@ -211,12 +213,12 @@ func ClientHandshakeWithOptions(rawConn net.Conn, cfg *ProtocolConfig, opt Clien } if !cfg.DisableHTTPMask { - if err := WriteHTTPMaskHeader(rawConn, cfg.ServerAddress, opt.HTTPMaskStrategy); err != nil { + if err := WriteHTTPMaskHeader(rawConn, cfg.ServerAddress, cfg.HTTPMaskPathRoot, opt.HTTPMaskStrategy); err != nil { return nil, fmt.Errorf("write http mask failed: %w", err) } } - table, tableID, err := pickClientTable(cfg) + table, err := pickClientTable(cfg) if err != nil { return nil, err } @@ -228,9 +230,6 @@ func ClientHandshakeWithOptions(rawConn net.Conn, cfg *ProtocolConfig, opt Clien } handshake := buildHandshakePayload(cfg.Key) - if len(cfg.tableCandidates()) > 1 { - handshake[8] = tableID - } if _, err := cConn.Write(handshake[:]); err != nil { cConn.Close() return nil, fmt.Errorf("send handshake failed: %w", err) @@ -376,19 +375,9 @@ func normalizeHTTPMaskStrategy(strategy string) string { } } -// randomByte returns a cryptographically random byte (with a math/rand fallback). -func randomByte() byte { - var b [1]byte - if _, err := rand.Read(b[:]); err == nil { - return b[0] - } - return byte(time.Now().UnixNano()) -} - func userHashFromHandshake(handshakeBuf []byte) string { if len(handshakeBuf) < 16 { return "" } - // handshake[8] may be a table ID when table rotation is enabled; use [9:16] as stable user hash bytes. - return hex.EncodeToString(handshakeBuf[9:16]) + return hex.EncodeToString(handshakeBuf[8:16]) } diff --git a/transport/sudoku/httpmask_strategy.go b/transport/sudoku/httpmask_strategy.go index fa11b249..5b98bbd0 100644 --- a/transport/sudoku/httpmask_strategy.go +++ b/transport/sudoku/httpmask_strategy.go @@ -7,6 +7,7 @@ import ( "math/rand" "net" "strconv" + "strings" "sync" "time" @@ -92,24 +93,24 @@ func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte { // WriteHTTPMaskHeader writes an HTTP/1.x request header as a mask, according to strategy. // Supported strategies: ""/"random", "post", "websocket". -func WriteHTTPMaskHeader(w io.Writer, host string, strategy string) error { +func WriteHTTPMaskHeader(w io.Writer, host string, pathRoot string, strategy string) error { switch normalizeHTTPMaskStrategy(strategy) { case "random": - return httpmask.WriteRandomRequestHeader(w, host) + return httpmask.WriteRandomRequestHeaderWithPathRoot(w, host, pathRoot) case "post": - return writeHTTPMaskPOST(w, host) + return writeHTTPMaskPOST(w, host, pathRoot) case "websocket": - return writeHTTPMaskWebSocket(w, host) + return writeHTTPMaskWebSocket(w, host, pathRoot) default: return fmt.Errorf("unsupported http-mask-strategy: %s", strategy) } } -func writeHTTPMaskPOST(w io.Writer, host string) error { +func writeHTTPMaskPOST(w io.Writer, host string, pathRoot string) error { r := httpMaskRngPool.Get().(*rand.Rand) defer httpMaskRngPool.Put(r) - path := httpMaskPaths[r.Intn(len(httpMaskPaths))] + path := joinPathRoot(pathRoot, httpMaskPaths[r.Intn(len(httpMaskPaths))]) ctype := httpMaskContentTypes[r.Intn(len(httpMaskContentTypes))] bufPtr := httpMaskBufPool.Get().(*[]byte) @@ -140,11 +141,11 @@ func writeHTTPMaskPOST(w io.Writer, host string) error { return err } -func writeHTTPMaskWebSocket(w io.Writer, host string) error { +func writeHTTPMaskWebSocket(w io.Writer, host string, pathRoot string) error { r := httpMaskRngPool.Get().(*rand.Rand) defer httpMaskRngPool.Put(r) - path := httpMaskPaths[r.Intn(len(httpMaskPaths))] + path := joinPathRoot(pathRoot, httpMaskPaths[r.Intn(len(httpMaskPaths))]) bufPtr := httpMaskBufPool.Get().(*[]byte) buf := *bufPtr @@ -177,3 +178,37 @@ func writeHTTPMaskWebSocket(w io.Writer, host string) error { _, err := w.Write(buf) return err } + +func normalizePathRoot(root string) string { + root = strings.TrimSpace(root) + root = strings.Trim(root, "/") + if root == "" { + return "" + } + for i := 0; i < len(root); i++ { + c := root[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '_' || c == '-': + default: + return "" + } + } + return "/" + root +} + +func joinPathRoot(root, path string) string { + root = normalizePathRoot(root) + if root == "" { + return path + } + if path == "" { + return root + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return root + path +} diff --git a/transport/sudoku/httpmask_tunnel.go b/transport/sudoku/httpmask_tunnel.go index 48d1846c..45d79abc 100644 --- a/transport/sudoku/httpmask_tunnel.go +++ b/transport/sudoku/httpmask_tunnel.go @@ -23,7 +23,11 @@ func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer { if !cfg.DisableHTTPMask { switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { case "stream", "poll", "auto": - ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{Mode: cfg.HTTPMaskMode}) + ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{ + Mode: cfg.HTTPMaskMode, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: ClientAEADSeed(cfg.Key), + }) } } return &HTTPMaskTunnelServer{cfg: cfg, ts: ts} @@ -67,7 +71,7 @@ func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Con type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error) // DialHTTPMaskTunnel dials a CDN-capable HTTP tunnel (stream/poll/auto) and returns a stream carrying raw Sudoku bytes. -func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer) (net.Conn, error) { +func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) { if cfg == nil { return nil, fmt.Errorf("config is required") } @@ -83,14 +87,19 @@ func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *Protocol Mode: cfg.HTTPMaskMode, TLSEnabled: cfg.HTTPMaskTLSEnabled, HostOverride: cfg.HTTPMaskHost, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: ClientAEADSeed(cfg.Key), + Upgrade: upgrade, Multiplex: cfg.HTTPMaskMultiplex, DialContext: dial, }) } type HTTPMaskTunnelClient struct { - mode string - client *httpmask.TunnelClient + mode string + pathRoot string + authKey string + client *httpmask.TunnelClient } func NewHTTPMaskTunnelClient(serverAddress string, cfg *ProtocolConfig, dial TunnelDialer) (*HTTPMaskTunnelClient, error) { @@ -121,16 +130,23 @@ func NewHTTPMaskTunnelClient(serverAddress string, cfg *ProtocolConfig, dial Tun } return &HTTPMaskTunnelClient{ - mode: cfg.HTTPMaskMode, - client: c, + mode: cfg.HTTPMaskMode, + pathRoot: cfg.HTTPMaskPathRoot, + authKey: ClientAEADSeed(cfg.Key), + client: c, }, nil } -func (c *HTTPMaskTunnelClient) Dial(ctx context.Context) (net.Conn, error) { +func (c *HTTPMaskTunnelClient) Dial(ctx context.Context, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) { if c == nil || c.client == nil { return nil, fmt.Errorf("nil httpmask tunnel client") } - return c.client.DialTunnel(ctx, c.mode) + return c.client.DialTunnel(ctx, httpmask.TunnelDialOptions{ + Mode: c.mode, + PathRoot: c.pathRoot, + AuthKey: c.authKey, + Upgrade: upgrade, + }) } func (c *HTTPMaskTunnelClient) CloseIdleConnections() { diff --git a/transport/sudoku/httpmask_tunnel_test.go b/transport/sudoku/httpmask_tunnel_test.go index eab310f9..d831c53f 100644 --- a/transport/sudoku/httpmask_tunnel_test.go +++ b/transport/sudoku/httpmask_tunnel_test.go @@ -154,7 +154,7 @@ func TestHTTPMaskTunnel_Stream_TCPRoundTrip(t *testing.T) { clientCfg.ServerAddress = addr clientCfg.HTTPMaskHost = "example.com" - tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, nil) if err != nil { t.Fatalf("dial tunnel: %v", err) } @@ -225,7 +225,7 @@ func TestHTTPMaskTunnel_Poll_UoTRoundTrip(t *testing.T) { clientCfg := *serverCfg clientCfg.ServerAddress = addr - tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, nil) if err != nil { t.Fatalf("dial tunnel: %v", err) } @@ -287,7 +287,7 @@ func TestHTTPMaskTunnel_Auto_TCPRoundTrip(t *testing.T) { clientCfg := *serverCfg clientCfg.ServerAddress = addr - tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, nil) if err != nil { t.Fatalf("dial tunnel: %v", err) } @@ -331,13 +331,13 @@ func TestHTTPMaskTunnel_Validation(t *testing.T) { cfg.DisableHTTPMask = true cfg.HTTPMaskMode = "stream" - if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext); err == nil { + if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext, nil); err == nil { t.Fatalf("expected error for disabled http mask") } cfg.DisableHTTPMask = false cfg.HTTPMaskMode = "legacy" - if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext); err == nil { + if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext, nil); err == nil { t.Fatalf("expected error for legacy mode") } } @@ -385,7 +385,7 @@ func TestHTTPMaskTunnel_Soak_Concurrent(t *testing.T) { clientCfg.ServerAddress = addr clientCfg.HTTPMaskHost = strings.TrimSpace(clientCfg.HTTPMaskHost) - tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, nil) if err != nil { runErr <- fmt.Errorf("dial: %w", err) return diff --git a/transport/sudoku/multiplex_test.go b/transport/sudoku/multiplex_test.go index 694b6daa..93962906 100644 --- a/transport/sudoku/multiplex_test.go +++ b/transport/sudoku/multiplex_test.go @@ -99,7 +99,7 @@ func TestUserHash_StableAcrossTableRotation(t *testing.T) { if h == "" { t.Fatalf("empty user hash") } - if len(h) != 14 { + if len(h) != 16 { t.Fatalf("unexpected user hash length: %d", len(h)) } unique[h] = struct{}{} @@ -258,4 +258,3 @@ func TestMultiplex_Boundary_InvalidVersion(t *testing.T) { t.Fatalf("expected error") } } - diff --git a/transport/sudoku/obfs/httpmask/auth.go b/transport/sudoku/obfs/httpmask/auth.go new file mode 100644 index 00000000..3810cbbf --- /dev/null +++ b/transport/sudoku/obfs/httpmask/auth.go @@ -0,0 +1,137 @@ +package httpmask + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "strings" + "time" +) + +const ( + tunnelAuthHeaderKey = "Authorization" + tunnelAuthHeaderPrefix = "Bearer " +) + +type tunnelAuth struct { + key [32]byte // derived HMAC key + skew time.Duration +} + +func newTunnelAuth(key string, skew time.Duration) *tunnelAuth { + key = strings.TrimSpace(key) + if key == "" { + return nil + } + if skew <= 0 { + skew = 60 * time.Second + } + + // Domain separation: keep this HMAC key independent from other uses of cfg.Key. + h := sha256.New() + _, _ = h.Write([]byte("sudoku-httpmask-auth-v1:")) + _, _ = h.Write([]byte(key)) + + var sum [32]byte + h.Sum(sum[:0]) + + return &tunnelAuth{key: sum, skew: skew} +} + +func (a *tunnelAuth) token(mode TunnelMode, method, path string, now time.Time) string { + if a == nil { + return "" + } + + ts := now.Unix() + sig := a.sign(mode, method, path, ts) + + var buf [8 + 16]byte + binary.BigEndian.PutUint64(buf[:8], uint64(ts)) + copy(buf[8:], sig[:]) + return base64.RawURLEncoding.EncodeToString(buf[:]) +} + +func (a *tunnelAuth) verify(headers map[string]string, mode TunnelMode, method, path string, now time.Time) bool { + if a == nil { + return true + } + if headers == nil { + return false + } + + val := strings.TrimSpace(headers["authorization"]) + if val == "" { + return false + } + + // Accept both "Bearer " and raw token forms (for forward proxies / CDNs that may normalize headers). + if len(val) > len(tunnelAuthHeaderPrefix) && strings.EqualFold(val[:len(tunnelAuthHeaderPrefix)], tunnelAuthHeaderPrefix) { + val = strings.TrimSpace(val[len(tunnelAuthHeaderPrefix):]) + } + if val == "" { + return false + } + + raw, err := base64.RawURLEncoding.DecodeString(val) + if err != nil || len(raw) != 8+16 { + return false + } + + ts := int64(binary.BigEndian.Uint64(raw[:8])) + nowTS := now.Unix() + delta := nowTS - ts + if delta < 0 { + delta = -delta + } + if delta > int64(a.skew.Seconds()) { + return false + } + + want := a.sign(mode, method, path, ts) + return subtle.ConstantTimeCompare(raw[8:], want[:]) == 1 +} + +func (a *tunnelAuth) sign(mode TunnelMode, method, path string, ts int64) [16]byte { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + method = "GET" + } + path = strings.TrimSpace(path) + + var tsBuf [8]byte + binary.BigEndian.PutUint64(tsBuf[:], uint64(ts)) + + mac := hmac.New(sha256.New, a.key[:]) + _, _ = mac.Write([]byte(mode)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(method)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(path)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write(tsBuf[:]) + + var full [32]byte + mac.Sum(full[:0]) + + var out [16]byte + copy(out[:], full[:16]) + return out +} + +type headerSetter interface { + Set(key, value string) +} + +func applyTunnelAuthHeader(h headerSetter, auth *tunnelAuth, mode TunnelMode, method, path string) { + if auth == nil || h == nil { + return + } + token := auth.token(mode, method, path, time.Now()) + if token == "" { + return + } + h.Set(tunnelAuthHeaderKey, tunnelAuthHeaderPrefix+token) +} diff --git a/transport/sudoku/obfs/httpmask/masker.go b/transport/sudoku/obfs/httpmask/masker.go index 540a8911..4736d6ff 100644 --- a/transport/sudoku/obfs/httpmask/masker.go +++ b/transport/sudoku/obfs/httpmask/masker.go @@ -129,11 +129,17 @@ func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte { // WriteRandomRequestHeader writes a plausible HTTP/1.1 request header as a mask. func WriteRandomRequestHeader(w io.Writer, host string) error { + return WriteRandomRequestHeaderWithPathRoot(w, host, "") +} + +// WriteRandomRequestHeaderWithPathRoot is like WriteRandomRequestHeader but prefixes all paths with pathRoot. +// pathRoot must be a single segment (e.g. "aabbcc"); invalid inputs are treated as empty (disabled). +func WriteRandomRequestHeaderWithPathRoot(w io.Writer, host string, pathRoot string) error { // Get RNG from pool r := rngPool.Get().(*rand.Rand) defer rngPool.Put(r) - path := paths[r.Intn(len(paths))] + path := joinPathRoot(pathRoot, paths[r.Intn(len(paths))]) ctype := contentTypes[r.Intn(len(contentTypes))] // Use buffer pool diff --git a/transport/sudoku/obfs/httpmask/pathroot.go b/transport/sudoku/obfs/httpmask/pathroot.go new file mode 100644 index 00000000..0f5f7017 --- /dev/null +++ b/transport/sudoku/obfs/httpmask/pathroot.go @@ -0,0 +1,52 @@ +package httpmask + +import "strings" + +// normalizePathRoot normalizes the configured path root into "/" form. +// +// It is intentionally strict: only a single path segment is allowed, consisting of +// [A-Za-z0-9_-]. Invalid inputs are treated as empty (disabled). +func normalizePathRoot(root string) string { + root = strings.TrimSpace(root) + root = strings.Trim(root, "/") + if root == "" { + return "" + } + for i := 0; i < len(root); i++ { + c := root[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '_' || c == '-': + default: + return "" + } + } + return "/" + root +} + +func joinPathRoot(root, path string) string { + root = normalizePathRoot(root) + if root == "" { + return path + } + if path == "" { + return root + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return root + path +} + +func stripPathRoot(root, fullPath string) (string, bool) { + root = normalizePathRoot(root) + if root == "" { + return fullPath, true + } + if !strings.HasPrefix(fullPath, root+"/") { + return "", false + } + return strings.TrimPrefix(fullPath, root), true +} diff --git a/transport/sudoku/obfs/httpmask/tunnel.go b/transport/sudoku/obfs/httpmask/tunnel.go index 88bc1a3a..1d8fe905 100644 --- a/transport/sudoku/obfs/httpmask/tunnel.go +++ b/transport/sudoku/obfs/httpmask/tunnel.go @@ -62,6 +62,15 @@ type TunnelDialOptions struct { Mode string TLSEnabled bool // when true, use HTTPS; otherwise, use HTTP (no port-based inference) HostOverride string // optional Host header / SNI host (without scheme); accepts "example.com" or "example.com:443" + // PathRoot is an optional first-level path prefix for all HTTP tunnel endpoints. + // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... + PathRoot string + // AuthKey enables short-term HMAC auth for HTTP tunnel requests (anti-probing). + // When set (non-empty), each HTTP request carries an Authorization bearer token derived from AuthKey. + AuthKey string + // Upgrade optionally wraps the raw tunnel conn and/or writes a small prelude before DialTunnel returns. + // It is called with the raw tunnel conn; if it returns a non-nil conn, that conn is returned by DialTunnel. + Upgrade func(raw net.Conn) (net.Conn, error) // Multiplex controls whether the caller should reuse underlying HTTP connections (HTTP/1.1 keep-alive / HTTP/2). // To reuse across multiple dials, create a TunnelClient per proxy and reuse it. // Values: "off" disables reuse; "auto"/"on" enables it. @@ -109,34 +118,34 @@ func (c *TunnelClient) CloseIdleConnections() { c.transport.CloseIdleConnections() } -func (c *TunnelClient) DialTunnel(ctx context.Context, mode string) (net.Conn, error) { +func (c *TunnelClient) DialTunnel(ctx context.Context, opts TunnelDialOptions) (net.Conn, error) { if c == nil || c.client == nil { return nil, fmt.Errorf("nil tunnel client") } - tm := normalizeTunnelMode(mode) + tm := normalizeTunnelMode(opts.Mode) if tm == TunnelModeLegacy { return nil, fmt.Errorf("legacy mode does not use http tunnel") } switch tm { case TunnelModeStream: - return dialStreamWithClient(ctx, c.client, c.target) + return dialStreamWithClient(ctx, c.client, c.target, opts) case TunnelModePoll: - return dialPollWithClient(ctx, c.client, c.target) + return dialPollWithClient(ctx, c.client, c.target, opts) case TunnelModeAuto: streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second) - c1, errX := dialStreamWithClient(streamCtx, c.client, c.target) + c1, errX := dialStreamWithClient(streamCtx, c.client, c.target, opts) cancelX() if errX == nil { return c1, nil } - c2, errP := dialPollWithClient(ctx, c.client, c.target) + c2, errP := dialPollWithClient(ctx, c.client, c.target, opts) if errP == nil { return c2, nil } return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP) default: - return dialStreamWithClient(ctx, c.client, c.target) + return dialStreamWithClient(ctx, c.client, c.target, opts) } } @@ -248,8 +257,13 @@ func (c *httpStreamConn) Close() error { if c.cancel != nil { c.cancel() } - _ = c.writer.CloseWithError(io.ErrClosedPipe) - return c.reader.Close() + if c.writer != nil { + _ = c.writer.CloseWithError(io.ErrClosedPipe) + } + if c.reader != nil { + return c.reader.Close() + } + return nil } func (c *httpStreamConn) LocalAddr() net.Addr { return c.localAddr } @@ -320,20 +334,23 @@ type sessionDialInfo struct { pullURL string closeURL string headerHost string + auth *tunnelAuth } -func dialSessionWithClient(ctx context.Context, client *http.Client, target httpClientTarget, mode TunnelMode) (*sessionDialInfo, error) { +func dialSessionWithClient(ctx context.Context, client *http.Client, target httpClientTarget, mode TunnelMode, opts TunnelDialOptions) (*sessionDialInfo, error) { if client == nil { return nil, fmt.Errorf("nil http client") } - authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/session"}).String() + auth := newTunnelAuth(opts.AuthKey, 0) + authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/session")}).String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) if err != nil { return nil, err } req.Host = target.headerHost applyTunnelHeaders(req.Header, target.headerHost, mode) + applyTunnelAuthHeader(req.Header, auth, mode, http.MethodGet, "/session") resp, err := client.Do(req) if err != nil { @@ -356,9 +373,9 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http return nil, fmt.Errorf("%s authorize empty token", mode) } - pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token)}).String() - pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/stream", RawQuery: "token=" + url.QueryEscape(token)}).String() - closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() + pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token)}).String() + pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/stream"), RawQuery: "token=" + url.QueryEscape(token)}).String() + closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() return &sessionDialInfo{ client: client, @@ -366,6 +383,7 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http pullURL: pullURL, closeURL: closeURL, headerHost: target.headerHost, + auth: auth, }, nil } @@ -374,10 +392,10 @@ func dialSession(ctx context.Context, serverAddress string, opts TunnelDialOptio if err != nil { return nil, err } - return dialSessionWithClient(ctx, client, target, mode) + return dialSessionWithClient(ctx, client, target, mode, opts) } -func bestEffortCloseSession(client *http.Client, closeURL, headerHost string, mode TunnelMode) { +func bestEffortCloseSession(client *http.Client, closeURL, headerHost string, mode TunnelMode, auth *tunnelAuth) { if client == nil || closeURL == "" || headerHost == "" { return } @@ -391,6 +409,7 @@ func bestEffortCloseSession(client *http.Client, closeURL, headerHost string, mo } req.Host = headerHost applyTunnelHeaders(req.Header, headerHost, mode) + applyTunnelAuthHeader(req.Header, auth, mode, http.MethodPost, "/api/v1/upload") resp, err := client.Do(req) if err != nil || resp == nil { @@ -400,13 +419,13 @@ func bestEffortCloseSession(client *http.Client, closeURL, headerHost string, mo _ = resp.Body.Close() } -func dialStreamWithClient(ctx context.Context, client *http.Client, target httpClientTarget) (net.Conn, error) { - // Prefer split session (Cloudflare-friendly). Fall back to stream-one for older servers / environments. - c, errSplit := dialStreamSplitWithClient(ctx, client, target) +func dialStreamWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { + // Prefer split-session (Cloudflare-friendly). Fall back to stream-one for older servers / environments. + c, errSplit := dialStreamSplitWithClient(ctx, client, target, opts) if errSplit == nil { return c, nil } - c2, errOne := dialStreamOneWithClient(ctx, client, target) + c2, errOne := dialStreamOneWithClient(ctx, client, target, opts) if errOne == nil { return c2, nil } @@ -414,7 +433,7 @@ func dialStreamWithClient(ctx context.Context, client *http.Client, target httpC } func dialStream(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { - // Prefer split session (Cloudflare-friendly). Fall back to stream-one for older servers / environments. + // Prefer split-session (Cloudflare-friendly). Fall back to stream-one for older servers / environments. c, errSplit := dialStreamSplit(ctx, serverAddress, opts) if errSplit == nil { return c, nil @@ -426,13 +445,15 @@ func dialStream(ctx context.Context, serverAddress string, opts TunnelDialOption return nil, fmt.Errorf("dial stream failed: split: %v; stream-one: %w", errSplit, errOne) } -func dialStreamOneWithClient(ctx context.Context, client *http.Client, target httpClientTarget) (net.Conn, error) { +func dialStreamOneWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { if client == nil { return nil, fmt.Errorf("nil http client") } + auth := newTunnelAuth(opts.AuthKey, 0) r := rngPool.Get().(*mrand.Rand) - path := paths[r.Intn(len(paths))] + basePath := paths[r.Intn(len(paths))] + path := joinPathRoot(opts.PathRoot, basePath) ctype := contentTypes[r.Intn(len(contentTypes))] rngPool.Put(r) @@ -454,6 +475,7 @@ func dialStreamOneWithClient(ctx context.Context, client *http.Client, target ht req.Host = target.headerHost applyTunnelHeaders(req.Header, target.headerHost, TunnelModeStream) + applyTunnelAuthHeader(req.Header, auth, TunnelModeStream, http.MethodPost, basePath) req.Header.Set("Content-Type", ctype) type doResult struct { @@ -466,33 +488,84 @@ func dialStreamOneWithClient(ctx context.Context, client *http.Client, target ht doCh <- doResult{resp: resp, err: doErr} }() - select { - case <-ctx.Done(): - connCancel() - _ = reqBodyW.Close() - return nil, ctx.Err() - case r := <-doCh: - if r.err != nil { - connCancel() - _ = reqBodyW.Close() - return nil, r.err - } - if r.resp.StatusCode != http.StatusOK { - defer r.resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(r.resp.Body, 4*1024)) - connCancel() - _ = reqBodyW.Close() - return nil, fmt.Errorf("stream bad status: %s (%s)", r.resp.Status, strings.TrimSpace(string(body))) - } - - return &httpStreamConn{ - reader: r.resp.Body, - writer: reqBodyW, - cancel: connCancel, - localAddr: &net.TCPAddr{}, - remoteAddr: &net.TCPAddr{}, - }, nil + streamConn := &httpStreamConn{ + writer: reqBodyW, + cancel: connCancel, + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, } + + type upgradeResult struct { + conn net.Conn + err error + } + upgradeCh := make(chan upgradeResult, 1) + if opts.Upgrade == nil { + upgradeCh <- upgradeResult{conn: streamConn, err: nil} + } else { + go func() { + upgradeConn, err := opts.Upgrade(streamConn) + if err != nil { + upgradeCh <- upgradeResult{conn: nil, err: err} + return + } + if upgradeConn == nil { + upgradeConn = streamConn + } + upgradeCh <- upgradeResult{conn: upgradeConn, err: nil} + }() + } + + var ( + outConn net.Conn + upgradeDone bool + responseReady bool + ) + + for !(upgradeDone && responseReady) { + select { + case <-ctx.Done(): + _ = streamConn.Close() + if outConn != nil && outConn != streamConn { + _ = outConn.Close() + } + return nil, ctx.Err() + + case u := <-upgradeCh: + if u.err != nil { + _ = streamConn.Close() + return nil, u.err + } + outConn = u.conn + if outConn == nil { + outConn = streamConn + } + upgradeDone = true + + case r := <-doCh: + if r.err != nil { + _ = streamConn.Close() + if outConn != nil && outConn != streamConn { + _ = outConn.Close() + } + return nil, r.err + } + if r.resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(r.resp.Body, 4*1024)) + _ = r.resp.Body.Close() + _ = streamConn.Close() + if outConn != nil && outConn != streamConn { + _ = outConn.Close() + } + return nil, fmt.Errorf("stream bad status: %s (%s)", r.resp.Status, strings.TrimSpace(string(body))) + } + + streamConn.reader = r.resp.Body + responseReady = true + } + } + + return outConn, nil } func dialStreamOne(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { @@ -500,7 +573,7 @@ func dialStreamOne(ctx context.Context, serverAddress string, opts TunnelDialOpt if err != nil { return nil, err } - return dialStreamOneWithClient(ctx, client, target) + return dialStreamOneWithClient(ctx, client, target, opts) } type queuedConn struct { @@ -599,6 +672,7 @@ type streamSplitConn struct { pullURL string closeURL string headerHost string + auth *tunnelAuth } func (c *streamSplitConn) Close() error { @@ -607,7 +681,7 @@ func (c *streamSplitConn) Close() error { if c.cancel != nil { c.cancel() } - bestEffortCloseSession(c.client, c.closeURL, c.headerHost, TunnelModeStream) + bestEffortCloseSession(c.client, c.closeURL, c.headerHost, TunnelModeStream, c.auth) return nil } @@ -625,6 +699,7 @@ func newStreamSplitConnFromInfo(info *sessionDialInfo) *streamSplitConn { pullURL: info.pullURL, closeURL: info.closeURL, headerHost: info.headerHost, + auth: info.auth, queuedConn: queuedConn{ rxc: make(chan []byte, 256), closed: make(chan struct{}), @@ -639,8 +714,8 @@ func newStreamSplitConnFromInfo(info *sessionDialInfo) *streamSplitConn { return c } -func dialStreamSplitWithClient(ctx context.Context, client *http.Client, target httpClientTarget) (net.Conn, error) { - info, err := dialSessionWithClient(ctx, client, target, TunnelModeStream) +func dialStreamSplitWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { + info, err := dialSessionWithClient(ctx, client, target, TunnelModeStream, opts) if err != nil { return nil, err } @@ -648,7 +723,18 @@ func dialStreamSplitWithClient(ctx context.Context, client *http.Client, target if c == nil { return nil, fmt.Errorf("failed to build stream split conn") } - return c, nil + outConn := net.Conn(c) + if opts.Upgrade != nil { + upgraded, err := opts.Upgrade(c) + if err != nil { + _ = c.Close() + return nil, err + } + if upgraded != nil { + outConn = upgraded + } + } + return outConn, nil } func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { @@ -660,7 +746,18 @@ func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialO if c == nil { return nil, fmt.Errorf("failed to build stream split conn") } - return c, nil + outConn := net.Conn(c) + if opts.Upgrade != nil { + upgraded, err := opts.Upgrade(c) + if err != nil { + _ = c.Close() + return nil, err + } + if upgraded != nil { + outConn = upgraded + } + } + return outConn, nil } func (c *streamSplitConn) pullLoop() { @@ -696,6 +793,7 @@ func (c *streamSplitConn) pullLoop() { } req.Host = c.headerHost applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + applyTunnelAuthHeader(req.Header, c.auth, TunnelModeStream, http.MethodGet, "/stream") resp, err := c.client.Do(req) if err != nil { @@ -793,6 +891,7 @@ func (c *streamSplitConn) pushLoop() { } req.Host = c.headerHost applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + applyTunnelAuthHeader(req.Header, c.auth, TunnelModeStream, http.MethodPost, "/api/v1/upload") req.Header.Set("Content-Type", "application/octet-stream") resp, err := c.client.Do(req) @@ -896,6 +995,7 @@ type pollConn struct { pullURL string closeURL string headerHost string + auth *tunnelAuth } func isDialError(err error) bool { @@ -917,7 +1017,7 @@ func (c *pollConn) closeWithError(err error) error { if c.cancel != nil { c.cancel() } - bestEffortCloseSession(c.client, c.closeURL, c.headerHost, TunnelModePoll) + bestEffortCloseSession(c.client, c.closeURL, c.headerHost, TunnelModePoll, c.auth) return nil } @@ -939,6 +1039,7 @@ func newPollConnFromInfo(info *sessionDialInfo) *pollConn { pullURL: info.pullURL, closeURL: info.closeURL, headerHost: info.headerHost, + auth: info.auth, queuedConn: queuedConn{ rxc: make(chan []byte, 128), closed: make(chan struct{}), @@ -953,8 +1054,8 @@ func newPollConnFromInfo(info *sessionDialInfo) *pollConn { return c } -func dialPollWithClient(ctx context.Context, client *http.Client, target httpClientTarget) (net.Conn, error) { - info, err := dialSessionWithClient(ctx, client, target, TunnelModePoll) +func dialPollWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { + info, err := dialSessionWithClient(ctx, client, target, TunnelModePoll, opts) if err != nil { return nil, err } @@ -962,7 +1063,18 @@ func dialPollWithClient(ctx context.Context, client *http.Client, target httpCli if c == nil { return nil, fmt.Errorf("failed to build poll conn") } - return c, nil + outConn := net.Conn(c) + if opts.Upgrade != nil { + upgraded, err := opts.Upgrade(c) + if err != nil { + _ = c.Close() + return nil, err + } + if upgraded != nil { + outConn = upgraded + } + } + return outConn, nil } func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { @@ -974,7 +1086,18 @@ func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions) if c == nil { return nil, fmt.Errorf("failed to build poll conn") } - return c, nil + outConn := net.Conn(c) + if opts.Upgrade != nil { + upgraded, err := opts.Upgrade(c) + if err != nil { + _ = c.Close() + return nil, err + } + if upgraded != nil { + outConn = upgraded + } + } + return outConn, nil } func (c *pollConn) pullLoop() { @@ -1001,6 +1124,7 @@ func (c *pollConn) pullLoop() { } req.Host = c.headerHost applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + applyTunnelAuthHeader(req.Header, c.auth, TunnelModePoll, http.MethodGet, "/stream") resp, err := c.client.Do(req) if err != nil { @@ -1084,6 +1208,7 @@ func (c *pollConn) pushLoop() { } req.Host = c.headerHost applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + applyTunnelAuthHeader(req.Header, c.auth, TunnelModePoll, http.MethodPost, "/api/v1/upload") req.Header.Set("Content-Type", "text/plain") resp, err := c.client.Do(req) @@ -1246,6 +1371,18 @@ func applyTunnelHeaders(h http.Header, host string, mode TunnelMode) { type TunnelServerOptions struct { Mode string + // PathRoot is an optional first-level path prefix for all HTTP tunnel endpoints. + // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... + PathRoot string + // AuthKey enables short-term HMAC auth for HTTP tunnel requests (anti-probing). + // When set (non-empty), the server requires each request to carry a valid Authorization bearer token. + AuthKey string + // AuthSkew controls allowed clock skew / replay window for AuthKey. 0 uses a conservative default. + AuthSkew time.Duration + // PassThroughOnReject controls how the server handles "recognized but rejected" tunnel requests + // (e.g., wrong mode / wrong path / invalid token). When true, the request bytes are replayed back + // to the caller as HandlePassThrough to allow higher-level fallback handling. + PassThroughOnReject bool // PullReadTimeout controls how long the server long-poll waits for tunnel downlink data before replying with a keepalive newline. PullReadTimeout time.Duration // SessionTTL is a best-effort TTL to prevent leaked sessions. 0 uses a conservative default. @@ -1253,7 +1390,10 @@ type TunnelServerOptions struct { } type TunnelServer struct { - mode TunnelMode + mode TunnelMode + pathRoot string + passThroughOnReject bool + auth *tunnelAuth pullReadTimeout time.Duration sessionTTL time.Duration @@ -1272,6 +1412,8 @@ func NewTunnelServer(opts TunnelServerOptions) *TunnelServer { if mode == TunnelModeLegacy { // Server-side "legacy" means: don't accept stream/poll tunnels; only passthrough. } + pathRoot := normalizePathRoot(opts.PathRoot) + auth := newTunnelAuth(opts.AuthKey, opts.AuthSkew) timeout := opts.PullReadTimeout if timeout <= 0 { timeout = 10 * time.Second @@ -1281,10 +1423,13 @@ func NewTunnelServer(opts TunnelServerOptions) *TunnelServer { ttl = 2 * time.Minute } return &TunnelServer{ - mode: mode, - pullReadTimeout: timeout, - sessionTTL: ttl, - sessions: make(map[string]*tunnelSession), + mode: mode, + pathRoot: pathRoot, + auth: auth, + passThroughOnReject: opts.PassThroughOnReject, + pullReadTimeout: timeout, + sessionTTL: ttl, + sessions: make(map[string]*tunnelSession), } } @@ -1340,6 +1485,12 @@ func (s *TunnelServer) HandleConn(rawConn net.Conn) (HandleResult, net.Conn, err return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil } if s.mode == TunnelModeLegacy { + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") _ = rawConn.Close() return HandleDone, nil, nil @@ -1348,19 +1499,37 @@ func (s *TunnelServer) HandleConn(rawConn net.Conn) (HandleResult, net.Conn, err switch TunnelMode(tunnelHeader) { case TunnelModeStream: if s.mode != TunnelModeStream && s.mode != TunnelModeAuto { + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") _ = rawConn.Close() return HandleDone, nil, nil } - return s.handleStream(rawConn, req, buffered) + return s.handleStream(rawConn, req, headerBytes, buffered) case TunnelModePoll: if s.mode != TunnelModePoll && s.mode != TunnelModeAuto { + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") _ = rawConn.Close() return HandleDone, nil, nil } - return s.handlePoll(rawConn, req, buffered) + return s.handlePoll(rawConn, req, headerBytes, buffered) default: + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") _ = rawConn.Close() return HandleDone, nil, nil @@ -1507,19 +1676,31 @@ func (c *bodyConn) Close() error { return firstErr } -func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, buffered []byte) (HandleResult, net.Conn, error) { - u, err := url.ParseRequestURI(req.target) - if err != nil { - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") +func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, headerBytes []byte, buffered []byte) (HandleResult, net.Conn, error) { + rejectOrReply := func(code int, body string) (HandleResult, net.Conn, error) { + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + _ = writeSimpleHTTPResponse(rawConn, code, body) _ = rawConn.Close() return HandleDone, nil, nil } + u, err := url.ParseRequestURI(req.target) + if err != nil { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + // Only accept plausible paths to reduce accidental exposure. - if !isAllowedPath(req.target) { - _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") - _ = rawConn.Close() - return HandleDone, nil, nil + path, ok := stripPathRoot(s.pathRoot, u.Path) + if !ok || !s.isAllowedBasePath(path) { + return rejectOrReply(http.StatusNotFound, "not found") + } + if !s.auth.verify(req.headers, TunnelModeStream, req.method, path, time.Now()) { + return rejectOrReply(http.StatusNotFound, "not found") } token := u.Query().Get("token") @@ -1528,31 +1709,25 @@ func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, bu switch strings.ToUpper(req.method) { case http.MethodGet: // Stream split-session: GET /session (no token) => token + start tunnel on a server-side pipe. - if token == "" && u.Path == "/session" { + if token == "" && path == "/session" { return s.authorizeSession(rawConn) } // Stream split-session: GET /stream?token=... => downlink poll. - if token != "" && u.Path == "/stream" { + if token != "" && path == "/stream" { return s.streamPull(rawConn, token) } - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusBadRequest, "bad request") case http.MethodPost: // Stream split-session: POST /api/v1/upload?token=... => uplink push. - if token != "" && u.Path == "/api/v1/upload" { + if token != "" && path == "/api/v1/upload" { if closeFlag { s.closeSession(token) - _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusOK, "") } bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) if err != nil { - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusBadRequest, "bad request") } return s.streamPush(rawConn, token, bodyReader) } @@ -1581,19 +1756,13 @@ func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, bu return HandleStartTunnel, stream, nil default: - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusBadRequest, "bad request") } } -func isAllowedPath(target string) bool { - u, err := url.ParseRequestURI(target) - if err != nil { - return false - } +func (s *TunnelServer) isAllowedBasePath(path string) bool { for _, p := range paths { - if u.Path == p { + if path == p { return true } } @@ -1650,51 +1819,58 @@ func writeTokenHTTPResponse(w io.Writer, token string) error { return err } -func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, buffered []byte) (HandleResult, net.Conn, error) { - u, err := url.ParseRequestURI(req.target) - if err != nil { - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") +func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, headerBytes []byte, buffered []byte) (HandleResult, net.Conn, error) { + rejectOrReply := func(code int, body string) (HandleResult, net.Conn, error) { + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + _ = writeSimpleHTTPResponse(rawConn, code, body) _ = rawConn.Close() return HandleDone, nil, nil } - if !isAllowedPath(req.target) { - _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") - _ = rawConn.Close() - return HandleDone, nil, nil + u, err := url.ParseRequestURI(req.target) + if err != nil { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + + path, ok := stripPathRoot(s.pathRoot, u.Path) + if !ok || !s.isAllowedBasePath(path) { + return rejectOrReply(http.StatusNotFound, "not found") + } + if !s.auth.verify(req.headers, TunnelModePoll, req.method, path, time.Now()) { + return rejectOrReply(http.StatusNotFound, "not found") } token := u.Query().Get("token") closeFlag := u.Query().Get("close") == "1" switch strings.ToUpper(req.method) { case http.MethodGet: - if token == "" { + if token == "" && path == "/session" { return s.authorizeSession(rawConn) } - return s.pollPull(rawConn, token) + if token != "" && path == "/stream" { + return s.pollPull(rawConn, token) + } + return rejectOrReply(http.StatusBadRequest, "bad request") case http.MethodPost: - if token == "" { - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "missing token") - _ = rawConn.Close() - return HandleDone, nil, nil + if token == "" || path != "/api/v1/upload" { + return rejectOrReply(http.StatusBadRequest, "bad request") } if closeFlag { s.closeSession(token) - _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusOK, "") } bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) if err != nil { - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusBadRequest, "bad request") } return s.pollPush(rawConn, token, bodyReader) default: - _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") - _ = rawConn.Close() - return HandleDone, nil, nil + return rejectOrReply(http.StatusBadRequest, "bad request") } } diff --git a/transport/sudoku/obfs/sudoku/layout.go b/transport/sudoku/obfs/sudoku/layout.go index 72c569f5..8e5315f3 100644 --- a/transport/sudoku/obfs/sudoku/layout.go +++ b/transport/sudoku/obfs/sudoku/layout.go @@ -20,7 +20,11 @@ type byteLayout struct { } func (l *byteLayout) isHint(b byte) bool { - return (b & l.hintMask) == l.hintValue + if (b & l.hintMask) == l.hintValue { + return true + } + // ASCII layout maps the single non-printable marker (0x7F) to '\n' on the wire. + return l.name == "ascii" && b == '\n' } // resolveLayout picks the byte layout based on ASCII preference and optional custom pattern. @@ -53,12 +57,25 @@ func newASCIILayout() *byteLayout { padMarker: 0x3F, paddingPool: padding, encodeHint: func(val, pos byte) byte { - return 0x40 | ((val & 0x03) << 4) | (pos & 0x0F) + b := 0x40 | ((val & 0x03) << 4) | (pos & 0x0F) + // Avoid DEL (0x7F) in prefer_ascii mode; map it to '\n' to reduce fingerprint. + if b == 0x7F { + return '\n' + } + return b }, encodeGroup: func(group byte) byte { - return 0x40 | (group & 0x3F) + b := 0x40 | (group & 0x3F) + // Avoid DEL (0x7F) in prefer_ascii mode; map it to '\n' to reduce fingerprint. + if b == 0x7F { + return '\n' + } + return b }, decodeGroup: func(b byte) (byte, bool) { + if b == '\n' { + return 0x3F, true + } if (b & 0x40) == 0 { return 0, false } diff --git a/transport/sudoku/table_probe.go b/transport/sudoku/table_probe.go index 8def6fd4..c885756e 100644 --- a/transport/sudoku/table_probe.go +++ b/transport/sudoku/table_probe.go @@ -3,6 +3,7 @@ package sudoku import ( "bufio" "bytes" + crand "crypto/rand" "encoding/binary" "errors" "fmt" @@ -14,16 +15,20 @@ import ( "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) -func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, byte, error) { +func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, error) { candidates := cfg.tableCandidates() if len(candidates) == 0 { - return nil, 0, fmt.Errorf("no table configured") + return nil, fmt.Errorf("no table configured") } if len(candidates) == 1 { - return candidates[0], 0, nil + return candidates[0], nil } - idx := int(randomByte()) % len(candidates) - return candidates[idx], byte(idx), nil + var b [1]byte + if _, err := crand.Read(b[:]); err != nil { + return nil, fmt.Errorf("random table pick failed: %w", err) + } + idx := int(b[0]) % len(candidates) + return candidates[idx], nil } type readOnlyConn struct {