diff --git a/.github/update.log b/.github/update.log index 0683fc1e40..4d96a98492 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1242,3 +1242,4 @@ Update On Sat Jan 10 19:39:24 CET 2026 Update On Sun Jan 11 19:40:53 CET 2026 Update On Mon Jan 12 19:44:25 CET 2026 Update On Tue Jan 13 19:42:52 CET 2026 +Update On Wed Jan 14 19:44:12 CET 2026 diff --git a/clash-meta/adapter/outbound/sudoku.go b/clash-meta/adapter/outbound/sudoku.go index 8f952dd68a..0f962e1ccd 100644 --- a/clash-meta/adapter/outbound/sudoku.go +++ b/clash-meta/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/clash-meta/adapter/outbound/trojan.go b/clash-meta/adapter/outbound/trojan.go index 3390c57709..b868eb8d5b 100644 --- a/clash-meta/adapter/outbound/trojan.go +++ b/clash-meta/adapter/outbound/trojan.go @@ -356,6 +356,7 @@ func NewTrojan(option TrojanOption) (*Trojan, error) { t.gunTLSConfig = tlsConfig t.gunConfig = &gun.Config{ ServiceName: option.GrpcOpts.GrpcServiceName, + UserAgent: option.GrpcOpts.GrpcUserAgent, Host: option.SNI, ClientFingerprint: option.ClientFingerprint, } diff --git a/clash-meta/adapter/outbound/vless.go b/clash-meta/adapter/outbound/vless.go index 3ca1bb9f79..603d433e64 100644 --- a/clash-meta/adapter/outbound/vless.go +++ b/clash-meta/adapter/outbound/vless.go @@ -462,6 +462,7 @@ func NewVless(option VlessOption) (*Vless, error) { gunConfig := &gun.Config{ ServiceName: v.option.GrpcOpts.GrpcServiceName, + UserAgent: v.option.GrpcOpts.GrpcUserAgent, Host: v.option.ServerName, ClientFingerprint: v.option.ClientFingerprint, } diff --git a/clash-meta/adapter/outbound/vmess.go b/clash-meta/adapter/outbound/vmess.go index 5654cd7c48..c9656bd900 100644 --- a/clash-meta/adapter/outbound/vmess.go +++ b/clash-meta/adapter/outbound/vmess.go @@ -86,6 +86,7 @@ type HTTP2Options struct { type GrpcOptions struct { GrpcServiceName string `proxy:"grpc-service-name,omitempty"` + GrpcUserAgent string `proxy:"grpc-user-agent,omitempty"` } type WSOptions struct { @@ -467,6 +468,7 @@ func NewVmess(option VmessOption) (*Vmess, error) { gunConfig := &gun.Config{ ServiceName: v.option.GrpcOpts.GrpcServiceName, + UserAgent: v.option.GrpcOpts.GrpcUserAgent, Host: v.option.ServerName, ClientFingerprint: v.option.ClientFingerprint, } diff --git a/clash-meta/docs/config.yaml b/clash-meta/docs/config.yaml index 69107872d9..2722f55add 100644 --- a/clash-meta/docs/config.yaml +++ b/clash-meta/docs/config.yaml @@ -669,6 +669,7 @@ proxies: # socks5 # skip-cert-verify: true grpc-opts: grpc-service-name: "example" + # grpc-user-agent: "grpc-go/1.36.0" # ip-version: ipv4 # vless @@ -757,6 +758,8 @@ proxies: # socks5 servername: testingcf.jsdelivr.net grpc-opts: grpc-service-name: "grpc" + # grpc-user-agent: "grpc-go/1.36.0" + reality-opts: public-key: CrrQSjAG_YkHLwvM2M-7XkKJilgL5upBKCp0od0tLhE short-id: 10f897e26c4b9478 @@ -825,6 +828,7 @@ proxies: # socks5 udp: true grpc-opts: grpc-service-name: "example" + # grpc-user-agent: "grpc-go/1.36.0" - name: trojan-ws server: server @@ -1068,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) @@ -1371,7 +1376,7 @@ listeners: # dC5jb20AAA== # -----END ECH KEYS----- - - name: reidr-in-1 + - name: redir-in-1 type: redir port: 10811 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 @@ -1617,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/clash-meta/go.mod b/clash-meta/go.mod index 93c9499664..a116c1196e 100644 --- a/clash-meta/go.mod +++ b/clash-meta/go.mod @@ -41,7 +41,7 @@ require ( github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 github.com/metacubex/tls v0.1.0 - github.com/metacubex/utls v1.8.3 + github.com/metacubex/utls v1.8.4 github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f github.com/miekg/dns v1.1.63 // lastest version compatible with golang1.20 github.com/mroth/weightedrand/v2 v2.1.0 diff --git a/clash-meta/go.sum b/clash-meta/go.sum index c67ff6c438..99f29c70a8 100644 --- a/clash-meta/go.sum +++ b/clash-meta/go.sum @@ -144,8 +144,8 @@ github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTiz github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= github.com/metacubex/tls v0.1.0 h1:1kjR/1q2uU1cZIwiHYEnWzS4L+0Cu1/X3yfIQ76BzNY= github.com/metacubex/tls v0.1.0/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= -github.com/metacubex/utls v1.8.3 h1:0m/yCxm3SK6kWve2lKiFb1pue1wHitJ8sQQD4Ikqde4= -github.com/metacubex/utls v1.8.3/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E= diff --git a/clash-meta/hub/executor/executor.go b/clash-meta/hub/executor/executor.go index 184e64de79..3091f83880 100644 --- a/clash-meta/hub/executor/executor.go +++ b/clash-meta/hub/executor/executor.go @@ -424,6 +424,9 @@ func updateGeneral(general *config.General, logging bool) { mihomoHttp.SetUA(general.GlobalUA) resource.SetETag(general.ETagSupport) + if general.GlobalClientFingerprint != "" { + log.Warnln("The `global-client-fingerprint` configuration is deprecated, please set `client-fingerprint` directly on the proxy instead") + } tlsC.SetGlobalFingerprint(general.GlobalClientFingerprint) } diff --git a/clash-meta/listener/config/sudoku.go b/clash-meta/listener/config/sudoku.go index 118e252cad..e22f241832 100644 --- a/clash-meta/listener/config/sudoku.go +++ b/clash-meta/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/clash-meta/listener/inbound/sudoku.go b/clash-meta/listener/inbound/sudoku.go index fc37cb7912..04b47de02d 100644 --- a/clash-meta/listener/inbound/sudoku.go +++ b/clash-meta/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/clash-meta/listener/sudoku/server.go b/clash-meta/listener/sudoku/server.go index d0d3b404c3..ca2cbe0ef5 100644 --- a/clash-meta/listener/sudoku/server.go +++ b/clash-meta/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/clash-meta/transport/gun/gun.go b/clash-meta/transport/gun/gun.go index 3ac548ddfc..7ca1f061f0 100644 --- a/clash-meta/transport/gun/gun.go +++ b/clash-meta/transport/gun/gun.go @@ -60,6 +60,7 @@ type Conn struct { type Config struct { ServiceName string + UserAgent string Host string ClientFingerprint string } @@ -347,6 +348,12 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er path := ServiceNameToPath(serviceName) reader, writer := io.Pipe() + + header := defaultHeader.Clone() + if cfg.UserAgent != "" { + header["user-agent"] = []string{cfg.UserAgent} + } + request := &http.Request{ Method: http.MethodPost, Body: reader, @@ -360,7 +367,7 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er Proto: "HTTP/2", ProtoMajor: 2, ProtoMinor: 0, - Header: defaultHeader, + Header: header, } request = request.WithContext(transport.ctx) diff --git a/clash-meta/transport/sudoku/config.go b/clash-meta/transport/sudoku/config.go index 27649fbc56..d13eab43df 100644 --- a/clash-meta/transport/sudoku/config.go +++ b/clash-meta/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/clash-meta/transport/sudoku/handshake.go b/clash-meta/transport/sudoku/handshake.go index 6963add595..3e75ac3cc2 100644 --- a/clash-meta/transport/sudoku/handshake.go +++ b/clash-meta/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/clash-meta/transport/sudoku/httpmask_strategy.go b/clash-meta/transport/sudoku/httpmask_strategy.go index fa11b24914..5b98bbd072 100644 --- a/clash-meta/transport/sudoku/httpmask_strategy.go +++ b/clash-meta/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/clash-meta/transport/sudoku/httpmask_tunnel.go b/clash-meta/transport/sudoku/httpmask_tunnel.go index 48d1846cef..45d79abc77 100644 --- a/clash-meta/transport/sudoku/httpmask_tunnel.go +++ b/clash-meta/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/clash-meta/transport/sudoku/httpmask_tunnel_test.go b/clash-meta/transport/sudoku/httpmask_tunnel_test.go index eab310f976..d831c53fc7 100644 --- a/clash-meta/transport/sudoku/httpmask_tunnel_test.go +++ b/clash-meta/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/clash-meta/transport/sudoku/multiplex_test.go b/clash-meta/transport/sudoku/multiplex_test.go index 694b6daab7..93962906bd 100644 --- a/clash-meta/transport/sudoku/multiplex_test.go +++ b/clash-meta/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/clash-meta/transport/sudoku/obfs/httpmask/auth.go b/clash-meta/transport/sudoku/obfs/httpmask/auth.go new file mode 100644 index 0000000000..3810cbbff3 --- /dev/null +++ b/clash-meta/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/clash-meta/transport/sudoku/obfs/httpmask/masker.go b/clash-meta/transport/sudoku/obfs/httpmask/masker.go index 540a8911e0..4736d6ff30 100644 --- a/clash-meta/transport/sudoku/obfs/httpmask/masker.go +++ b/clash-meta/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/clash-meta/transport/sudoku/obfs/httpmask/pathroot.go b/clash-meta/transport/sudoku/obfs/httpmask/pathroot.go new file mode 100644 index 0000000000..0f5f701762 --- /dev/null +++ b/clash-meta/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/clash-meta/transport/sudoku/obfs/httpmask/tunnel.go b/clash-meta/transport/sudoku/obfs/httpmask/tunnel.go index 88bc1a3a55..1d8fe9057a 100644 --- a/clash-meta/transport/sudoku/obfs/httpmask/tunnel.go +++ b/clash-meta/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/clash-meta/transport/sudoku/obfs/sudoku/layout.go b/clash-meta/transport/sudoku/obfs/sudoku/layout.go index 72c569f5cd..8e5315f3c7 100644 --- a/clash-meta/transport/sudoku/obfs/sudoku/layout.go +++ b/clash-meta/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/clash-meta/transport/sudoku/table_probe.go b/clash-meta/transport/sudoku/table_probe.go index 8def6fd488..c885756ea6 100644 --- a/clash-meta/transport/sudoku/table_probe.go +++ b/clash-meta/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 { diff --git a/clash-nyanpasu/manifest/version.json b/clash-nyanpasu/manifest/version.json index f9a82cd7ed..933d36da89 100644 --- a/clash-nyanpasu/manifest/version.json +++ b/clash-nyanpasu/manifest/version.json @@ -5,7 +5,7 @@ "mihomo_alpha": "alpha-c5b0f00", "clash_rs": "v0.9.3", "clash_premium": "2023-09-05-gdcc8d87", - "clash_rs_alpha": "0.9.3-alpha+sha.03dc0e4" + "clash_rs_alpha": "0.9.3-alpha+sha.d65b14e" }, "arch_template": { "mihomo": { @@ -69,5 +69,5 @@ "linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf" } }, - "updated_at": "2026-01-12T22:21:20.908Z" + "updated_at": "2026-01-13T22:22:05.276Z" } diff --git a/geoip/README.md b/geoip/README.md index 54d32f010a..6f486b7543 100644 --- a/geoip/README.md +++ b/geoip/README.md @@ -1,4 +1,4 @@ -# GeoIP 简介 [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Loyalsoldier/geoip) +# GeoIP 简介 [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Loyalsoldier/geoip) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/Loyalsoldier/geoip/total?logo=github) ![GitHub Downloads (all assets, latest release)](https://img.shields.io/github/downloads/Loyalsoldier/geoip/latest/total?logo=github) [![jsdelivr stats](https://data.jsdelivr.com/v1/package/gh/Loyalsoldier/geoip/badge?style=rounded)](https://www.jsdelivr.com/package/gh/Loyalsoldier/geoip) 本项目每周四自动生成多种格式 GeoIP 文件,同时提供命令行界面(CLI)工具供用户自行定制 GeoIP 文件,包括但不限于 V2Ray `dat` 格式文件 `geoip.dat`、MaxMind `mmdb` 格式文件 `Country.mmdb`、sing-box `SRS` 格式文件、mihomo `MRS` 格式文件、Clash ruleset 和 Surge ruleset。 diff --git a/lede/target/linux/generic/backport-6.12/422-v6.19-mtd-spinand-esmt-add-support-for-F50L1G41LC.patch b/lede/target/linux/generic/backport-6.12/422-v6.19-mtd-spinand-esmt-add-support-for-F50L1G41LC.patch new file mode 100644 index 0000000000..b65df4ea91 --- /dev/null +++ b/lede/target/linux/generic/backport-6.12/422-v6.19-mtd-spinand-esmt-add-support-for-F50L1G41LC.patch @@ -0,0 +1,84 @@ +From b98994cb9bc24f5c7575c86650f96c384576fdfa Mon Sep 17 00:00:00 2001 +From: Daniel Golle +Date: Mon, 17 Nov 2025 02:54:19 +0000 +Subject: [PATCH] mtd: spinand: esmt: add support for F50L1G41LC + +This adds support for ESMT F50L1G41LC, which appears to be an updated +version of the already supported F50L1G41LB. +Add esmt_8c SPI_NAND manufacturer to account for the newly used vendor +ID with support for the ESMT F50L1G41LC chip. + +Link: https://github.com/openwrt/openwrt/pull/15214#issuecomment-3514824435 +Signed-off-by: Daniel Golle +Signed-off-by: Miquel Raynal +--- + drivers/mtd/nand/spi/core.c | 1 + + drivers/mtd/nand/spi/esmt.c | 24 ++++++++++++++++++++++++ + include/linux/mtd/spinand.h | 1 + + 3 files changed, 26 insertions(+) + +--- a/drivers/mtd/nand/spi/core.c ++++ b/drivers/mtd/nand/spi/core.c +@@ -1114,6 +1114,7 @@ static const struct nand_ops spinand_ops + static const struct spinand_manufacturer *spinand_manufacturers[] = { + &alliancememory_spinand_manufacturer, + &ato_spinand_manufacturer, ++ &esmt_8c_spinand_manufacturer, + &esmt_c8_spinand_manufacturer, + &fmsh_spinand_manufacturer, + &foresee_spinand_manufacturer, +--- a/drivers/mtd/nand/spi/esmt.c ++++ b/drivers/mtd/nand/spi/esmt.c +@@ -11,6 +11,7 @@ + + /* ESMT uses GigaDevice 0xc8 JECDEC ID on some SPI NANDs */ + #define SPINAND_MFR_ESMT_C8 0xc8 ++#define SPINAND_MFR_ESMT_8C 0x8c + + static SPINAND_OP_VARIANTS(read_cache_variants, + SPINAND_PAGE_READ_FROM_CACHE_X4_OP(0, 1, NULL, 0), +@@ -102,6 +103,19 @@ static const struct mtd_ooblayout_ops f5 + .free = f50l1g41lb_ooblayout_free, + }; + ++ ++static const struct spinand_info esmt_8c_spinand_table[] = { ++ SPINAND_INFO("F50L1G41LC", ++ SPINAND_ID(SPINAND_READID_METHOD_OPCODE_ADDR, 0x2C), ++ NAND_MEMORG(1, 2048, 64, 64, 1024, 20, 1, 1, 1), ++ NAND_ECCREQ(1, 512), ++ SPINAND_INFO_OP_VARIANTS(&read_cache_variants, ++ &write_cache_variants, ++ &update_cache_variants), ++ 0, ++ SPINAND_ECCINFO(&f50l1g41lb_ooblayout, NULL)), ++}; ++ + static const struct spinand_info esmt_c8_spinand_table[] = { + SPINAND_INFO("F50L1G41LB", + SPINAND_ID(SPINAND_READID_METHOD_OPCODE_ADDR, 0x01, 0x7f, +@@ -138,6 +152,14 @@ static const struct spinand_info esmt_c8 + static const struct spinand_manufacturer_ops esmt_spinand_manuf_ops = { + }; + ++const struct spinand_manufacturer esmt_8c_spinand_manufacturer = { ++ .id = SPINAND_MFR_ESMT_8C, ++ .name = "ESMT", ++ .chips = esmt_8c_spinand_table, ++ .nchips = ARRAY_SIZE(esmt_8c_spinand_table), ++ .ops = &esmt_spinand_manuf_ops, ++}; ++ + const struct spinand_manufacturer esmt_c8_spinand_manufacturer = { + .id = SPINAND_MFR_ESMT_C8, + .name = "ESMT", +--- a/include/linux/mtd/spinand.h ++++ b/include/linux/mtd/spinand.h +@@ -262,6 +262,7 @@ struct spinand_manufacturer { + /* SPI NAND manufacturers */ + extern const struct spinand_manufacturer alliancememory_spinand_manufacturer; + extern const struct spinand_manufacturer ato_spinand_manufacturer; ++extern const struct spinand_manufacturer esmt_8c_spinand_manufacturer; + extern const struct spinand_manufacturer esmt_c8_spinand_manufacturer; + extern const struct spinand_manufacturer fmsh_spinand_manufacturer; + extern const struct spinand_manufacturer foresee_spinand_manufacturer; diff --git a/lede/target/linux/generic/backport-6.6/422-v6.19-mtd-spinand-esmt-add-support-for-F50L1G41LC.patch b/lede/target/linux/generic/backport-6.6/422-v6.19-mtd-spinand-esmt-add-support-for-F50L1G41LC.patch new file mode 100644 index 0000000000..f260d2ba8e --- /dev/null +++ b/lede/target/linux/generic/backport-6.6/422-v6.19-mtd-spinand-esmt-add-support-for-F50L1G41LC.patch @@ -0,0 +1,84 @@ +From b98994cb9bc24f5c7575c86650f96c384576fdfa Mon Sep 17 00:00:00 2001 +From: Daniel Golle +Date: Mon, 17 Nov 2025 02:54:19 +0000 +Subject: [PATCH] mtd: spinand: esmt: add support for F50L1G41LC + +This adds support for ESMT F50L1G41LC, which appears to be an updated +version of the already supported F50L1G41LB. +Add esmt_8c SPI_NAND manufacturer to account for the newly used vendor +ID with support for the ESMT F50L1G41LC chip. + +Link: https://github.com/openwrt/openwrt/pull/15214#issuecomment-3514824435 +Signed-off-by: Daniel Golle +Signed-off-by: Miquel Raynal +--- + drivers/mtd/nand/spi/core.c | 1 + + drivers/mtd/nand/spi/esmt.c | 24 ++++++++++++++++++++++++ + include/linux/mtd/spinand.h | 1 + + 3 files changed, 26 insertions(+) + +--- a/drivers/mtd/nand/spi/core.c ++++ b/drivers/mtd/nand/spi/core.c +@@ -942,6 +942,7 @@ static const struct nand_ops spinand_ops + static const struct spinand_manufacturer *spinand_manufacturers[] = { + &alliancememory_spinand_manufacturer, + &ato_spinand_manufacturer, ++ &esmt_8c_spinand_manufacturer, + &esmt_c8_spinand_manufacturer, + &fmsh_spinand_manufacturer, + &foresee_spinand_manufacturer, +--- a/drivers/mtd/nand/spi/esmt.c ++++ b/drivers/mtd/nand/spi/esmt.c +@@ -11,6 +11,7 @@ + + /* ESMT uses GigaDevice 0xc8 JECDEC ID on some SPI NANDs */ + #define SPINAND_MFR_ESMT_C8 0xc8 ++#define SPINAND_MFR_ESMT_8C 0x8c + + static SPINAND_OP_VARIANTS(read_cache_variants, + SPINAND_PAGE_READ_FROM_CACHE_X4_OP(0, 1, NULL, 0), +@@ -102,6 +103,19 @@ static const struct mtd_ooblayout_ops f5 + .free = f50l1g41lb_ooblayout_free, + }; + ++ ++static const struct spinand_info esmt_8c_spinand_table[] = { ++ SPINAND_INFO("F50L1G41LC", ++ SPINAND_ID(SPINAND_READID_METHOD_OPCODE_ADDR, 0x2C), ++ NAND_MEMORG(1, 2048, 64, 64, 1024, 20, 1, 1, 1), ++ NAND_ECCREQ(1, 512), ++ SPINAND_INFO_OP_VARIANTS(&read_cache_variants, ++ &write_cache_variants, ++ &update_cache_variants), ++ 0, ++ SPINAND_ECCINFO(&f50l1g41lb_ooblayout, NULL)), ++}; ++ + static const struct spinand_info esmt_c8_spinand_table[] = { + SPINAND_INFO("F50L1G41LB", + SPINAND_ID(SPINAND_READID_METHOD_OPCODE_ADDR, 0x01, 0x7f, +@@ -138,6 +152,14 @@ static const struct spinand_info esmt_c8 + static const struct spinand_manufacturer_ops esmt_spinand_manuf_ops = { + }; + ++const struct spinand_manufacturer esmt_8c_spinand_manufacturer = { ++ .id = SPINAND_MFR_ESMT_8C, ++ .name = "ESMT", ++ .chips = esmt_8c_spinand_table, ++ .nchips = ARRAY_SIZE(esmt_8c_spinand_table), ++ .ops = &esmt_spinand_manuf_ops, ++}; ++ + const struct spinand_manufacturer esmt_c8_spinand_manufacturer = { + .id = SPINAND_MFR_ESMT_C8, + .name = "ESMT", +--- a/include/linux/mtd/spinand.h ++++ b/include/linux/mtd/spinand.h +@@ -262,6 +262,7 @@ struct spinand_manufacturer { + /* SPI NAND manufacturers */ + extern const struct spinand_manufacturer alliancememory_spinand_manufacturer; + extern const struct spinand_manufacturer ato_spinand_manufacturer; ++extern const struct spinand_manufacturer esmt_8c_spinand_manufacturer; + extern const struct spinand_manufacturer esmt_c8_spinand_manufacturer; + extern const struct spinand_manufacturer fmsh_spinand_manufacturer; + extern const struct spinand_manufacturer foresee_spinand_manufacturer; diff --git a/lede/target/linux/generic/pending-6.12/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch b/lede/target/linux/generic/pending-6.12/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch index cc23125b3d..b8c1399f97 100644 --- a/lede/target/linux/generic/pending-6.12/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch +++ b/lede/target/linux/generic/pending-6.12/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch @@ -49,9 +49,9 @@ Submitted-by: Daniel Danzberger obj-$(CONFIG_MTD_SPI_NAND) += spinand.o --- a/drivers/mtd/nand/spi/core.c +++ b/drivers/mtd/nand/spi/core.c -@@ -1115,6 +1115,7 @@ static const struct spinand_manufacturer - &alliancememory_spinand_manufacturer, +@@ -1116,6 +1116,7 @@ static const struct spinand_manufacturer &ato_spinand_manufacturer, + &esmt_8c_spinand_manufacturer, &esmt_c8_spinand_manufacturer, + &etron_spinand_manufacturer, &foresee_spinand_manufacturer, @@ -160,9 +160,9 @@ Submitted-by: Daniel Danzberger +}; --- a/include/linux/mtd/spinand.h +++ b/include/linux/mtd/spinand.h -@@ -263,6 +263,7 @@ struct spinand_manufacturer { - extern const struct spinand_manufacturer alliancememory_spinand_manufacturer; +@@ -264,6 +264,7 @@ extern const struct spinand_manufacturer extern const struct spinand_manufacturer ato_spinand_manufacturer; + extern const struct spinand_manufacturer esmt_8c_spinand_manufacturer; extern const struct spinand_manufacturer esmt_c8_spinand_manufacturer; +extern const struct spinand_manufacturer etron_spinand_manufacturer; extern const struct spinand_manufacturer foresee_spinand_manufacturer; diff --git a/lede/target/linux/generic/pending-6.6/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch b/lede/target/linux/generic/pending-6.6/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch index 233122d7d4..06646df945 100644 --- a/lede/target/linux/generic/pending-6.6/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch +++ b/lede/target/linux/generic/pending-6.6/487-mtd-spinand-Add-support-for-Etron-EM73D044VCx.patch @@ -49,9 +49,9 @@ Submitted-by: Daniel Danzberger obj-$(CONFIG_MTD_SPI_NAND) += spinand.o --- a/drivers/mtd/nand/spi/core.c +++ b/drivers/mtd/nand/spi/core.c -@@ -943,6 +943,7 @@ static const struct spinand_manufacturer - &alliancememory_spinand_manufacturer, +@@ -944,6 +944,7 @@ static const struct spinand_manufacturer &ato_spinand_manufacturer, + &esmt_8c_spinand_manufacturer, &esmt_c8_spinand_manufacturer, + &etron_spinand_manufacturer, &foresee_spinand_manufacturer, @@ -160,9 +160,9 @@ Submitted-by: Daniel Danzberger +}; --- a/include/linux/mtd/spinand.h +++ b/include/linux/mtd/spinand.h -@@ -263,6 +263,7 @@ struct spinand_manufacturer { - extern const struct spinand_manufacturer alliancememory_spinand_manufacturer; +@@ -264,6 +264,7 @@ extern const struct spinand_manufacturer extern const struct spinand_manufacturer ato_spinand_manufacturer; + extern const struct spinand_manufacturer esmt_8c_spinand_manufacturer; extern const struct spinand_manufacturer esmt_c8_spinand_manufacturer; +extern const struct spinand_manufacturer etron_spinand_manufacturer; extern const struct spinand_manufacturer foresee_spinand_manufacturer; diff --git a/mihomo/adapter/outbound/sudoku.go b/mihomo/adapter/outbound/sudoku.go index 8f952dd68a..0f962e1ccd 100644 --- a/mihomo/adapter/outbound/sudoku.go +++ b/mihomo/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/mihomo/adapter/outbound/trojan.go b/mihomo/adapter/outbound/trojan.go index 3390c57709..b868eb8d5b 100644 --- a/mihomo/adapter/outbound/trojan.go +++ b/mihomo/adapter/outbound/trojan.go @@ -356,6 +356,7 @@ func NewTrojan(option TrojanOption) (*Trojan, error) { t.gunTLSConfig = tlsConfig t.gunConfig = &gun.Config{ ServiceName: option.GrpcOpts.GrpcServiceName, + UserAgent: option.GrpcOpts.GrpcUserAgent, Host: option.SNI, ClientFingerprint: option.ClientFingerprint, } diff --git a/mihomo/adapter/outbound/vless.go b/mihomo/adapter/outbound/vless.go index 3ca1bb9f79..603d433e64 100644 --- a/mihomo/adapter/outbound/vless.go +++ b/mihomo/adapter/outbound/vless.go @@ -462,6 +462,7 @@ func NewVless(option VlessOption) (*Vless, error) { gunConfig := &gun.Config{ ServiceName: v.option.GrpcOpts.GrpcServiceName, + UserAgent: v.option.GrpcOpts.GrpcUserAgent, Host: v.option.ServerName, ClientFingerprint: v.option.ClientFingerprint, } diff --git a/mihomo/adapter/outbound/vmess.go b/mihomo/adapter/outbound/vmess.go index 5654cd7c48..c9656bd900 100644 --- a/mihomo/adapter/outbound/vmess.go +++ b/mihomo/adapter/outbound/vmess.go @@ -86,6 +86,7 @@ type HTTP2Options struct { type GrpcOptions struct { GrpcServiceName string `proxy:"grpc-service-name,omitempty"` + GrpcUserAgent string `proxy:"grpc-user-agent,omitempty"` } type WSOptions struct { @@ -467,6 +468,7 @@ func NewVmess(option VmessOption) (*Vmess, error) { gunConfig := &gun.Config{ ServiceName: v.option.GrpcOpts.GrpcServiceName, + UserAgent: v.option.GrpcOpts.GrpcUserAgent, Host: v.option.ServerName, ClientFingerprint: v.option.ClientFingerprint, } diff --git a/mihomo/docs/config.yaml b/mihomo/docs/config.yaml index 69107872d9..2722f55add 100644 --- a/mihomo/docs/config.yaml +++ b/mihomo/docs/config.yaml @@ -669,6 +669,7 @@ proxies: # socks5 # skip-cert-verify: true grpc-opts: grpc-service-name: "example" + # grpc-user-agent: "grpc-go/1.36.0" # ip-version: ipv4 # vless @@ -757,6 +758,8 @@ proxies: # socks5 servername: testingcf.jsdelivr.net grpc-opts: grpc-service-name: "grpc" + # grpc-user-agent: "grpc-go/1.36.0" + reality-opts: public-key: CrrQSjAG_YkHLwvM2M-7XkKJilgL5upBKCp0od0tLhE short-id: 10f897e26c4b9478 @@ -825,6 +828,7 @@ proxies: # socks5 udp: true grpc-opts: grpc-service-name: "example" + # grpc-user-agent: "grpc-go/1.36.0" - name: trojan-ws server: server @@ -1068,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) @@ -1371,7 +1376,7 @@ listeners: # dC5jb20AAA== # -----END ECH KEYS----- - - name: reidr-in-1 + - name: redir-in-1 type: redir port: 10811 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 listen: 0.0.0.0 @@ -1617,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/mihomo/go.mod b/mihomo/go.mod index 93c9499664..a116c1196e 100644 --- a/mihomo/go.mod +++ b/mihomo/go.mod @@ -41,7 +41,7 @@ require ( github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 github.com/metacubex/tls v0.1.0 - github.com/metacubex/utls v1.8.3 + github.com/metacubex/utls v1.8.4 github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f github.com/miekg/dns v1.1.63 // lastest version compatible with golang1.20 github.com/mroth/weightedrand/v2 v2.1.0 diff --git a/mihomo/go.sum b/mihomo/go.sum index c67ff6c438..99f29c70a8 100644 --- a/mihomo/go.sum +++ b/mihomo/go.sum @@ -144,8 +144,8 @@ github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTiz github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= github.com/metacubex/tls v0.1.0 h1:1kjR/1q2uU1cZIwiHYEnWzS4L+0Cu1/X3yfIQ76BzNY= github.com/metacubex/tls v0.1.0/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= -github.com/metacubex/utls v1.8.3 h1:0m/yCxm3SK6kWve2lKiFb1pue1wHitJ8sQQD4Ikqde4= -github.com/metacubex/utls v1.8.3/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E= diff --git a/mihomo/hub/executor/executor.go b/mihomo/hub/executor/executor.go index 184e64de79..3091f83880 100644 --- a/mihomo/hub/executor/executor.go +++ b/mihomo/hub/executor/executor.go @@ -424,6 +424,9 @@ func updateGeneral(general *config.General, logging bool) { mihomoHttp.SetUA(general.GlobalUA) resource.SetETag(general.ETagSupport) + if general.GlobalClientFingerprint != "" { + log.Warnln("The `global-client-fingerprint` configuration is deprecated, please set `client-fingerprint` directly on the proxy instead") + } tlsC.SetGlobalFingerprint(general.GlobalClientFingerprint) } diff --git a/mihomo/listener/config/sudoku.go b/mihomo/listener/config/sudoku.go index 118e252cad..e22f241832 100644 --- a/mihomo/listener/config/sudoku.go +++ b/mihomo/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/mihomo/listener/inbound/sudoku.go b/mihomo/listener/inbound/sudoku.go index fc37cb7912..04b47de02d 100644 --- a/mihomo/listener/inbound/sudoku.go +++ b/mihomo/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/mihomo/listener/sudoku/server.go b/mihomo/listener/sudoku/server.go index d0d3b404c3..ca2cbe0ef5 100644 --- a/mihomo/listener/sudoku/server.go +++ b/mihomo/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/mihomo/transport/gun/gun.go b/mihomo/transport/gun/gun.go index 3ac548ddfc..7ca1f061f0 100644 --- a/mihomo/transport/gun/gun.go +++ b/mihomo/transport/gun/gun.go @@ -60,6 +60,7 @@ type Conn struct { type Config struct { ServiceName string + UserAgent string Host string ClientFingerprint string } @@ -347,6 +348,12 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er path := ServiceNameToPath(serviceName) reader, writer := io.Pipe() + + header := defaultHeader.Clone() + if cfg.UserAgent != "" { + header["user-agent"] = []string{cfg.UserAgent} + } + request := &http.Request{ Method: http.MethodPost, Body: reader, @@ -360,7 +367,7 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er Proto: "HTTP/2", ProtoMajor: 2, ProtoMinor: 0, - Header: defaultHeader, + Header: header, } request = request.WithContext(transport.ctx) diff --git a/mihomo/transport/sudoku/config.go b/mihomo/transport/sudoku/config.go index 27649fbc56..d13eab43df 100644 --- a/mihomo/transport/sudoku/config.go +++ b/mihomo/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/mihomo/transport/sudoku/handshake.go b/mihomo/transport/sudoku/handshake.go index 6963add595..3e75ac3cc2 100644 --- a/mihomo/transport/sudoku/handshake.go +++ b/mihomo/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/mihomo/transport/sudoku/httpmask_strategy.go b/mihomo/transport/sudoku/httpmask_strategy.go index fa11b24914..5b98bbd072 100644 --- a/mihomo/transport/sudoku/httpmask_strategy.go +++ b/mihomo/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/mihomo/transport/sudoku/httpmask_tunnel.go b/mihomo/transport/sudoku/httpmask_tunnel.go index 48d1846cef..45d79abc77 100644 --- a/mihomo/transport/sudoku/httpmask_tunnel.go +++ b/mihomo/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/mihomo/transport/sudoku/httpmask_tunnel_test.go b/mihomo/transport/sudoku/httpmask_tunnel_test.go index eab310f976..d831c53fc7 100644 --- a/mihomo/transport/sudoku/httpmask_tunnel_test.go +++ b/mihomo/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/mihomo/transport/sudoku/multiplex_test.go b/mihomo/transport/sudoku/multiplex_test.go index 694b6daab7..93962906bd 100644 --- a/mihomo/transport/sudoku/multiplex_test.go +++ b/mihomo/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/mihomo/transport/sudoku/obfs/httpmask/auth.go b/mihomo/transport/sudoku/obfs/httpmask/auth.go new file mode 100644 index 0000000000..3810cbbff3 --- /dev/null +++ b/mihomo/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/mihomo/transport/sudoku/obfs/httpmask/masker.go b/mihomo/transport/sudoku/obfs/httpmask/masker.go index 540a8911e0..4736d6ff30 100644 --- a/mihomo/transport/sudoku/obfs/httpmask/masker.go +++ b/mihomo/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/mihomo/transport/sudoku/obfs/httpmask/pathroot.go b/mihomo/transport/sudoku/obfs/httpmask/pathroot.go new file mode 100644 index 0000000000..0f5f701762 --- /dev/null +++ b/mihomo/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/mihomo/transport/sudoku/obfs/httpmask/tunnel.go b/mihomo/transport/sudoku/obfs/httpmask/tunnel.go index 88bc1a3a55..1d8fe9057a 100644 --- a/mihomo/transport/sudoku/obfs/httpmask/tunnel.go +++ b/mihomo/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/mihomo/transport/sudoku/obfs/sudoku/layout.go b/mihomo/transport/sudoku/obfs/sudoku/layout.go index 72c569f5cd..8e5315f3c7 100644 --- a/mihomo/transport/sudoku/obfs/sudoku/layout.go +++ b/mihomo/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/mihomo/transport/sudoku/table_probe.go b/mihomo/transport/sudoku/table_probe.go index 8def6fd488..c885756ea6 100644 --- a/mihomo/transport/sudoku/table_probe.go +++ b/mihomo/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 { diff --git a/openwrt-packages/luci-app-partexp/README.md b/openwrt-packages/luci-app-partexp/README.md index 5386a79d1d..4bff03fc99 100644 --- a/openwrt-packages/luci-app-partexp/README.md +++ b/openwrt-packages/luci-app-partexp/README.md @@ -20,15 +20,23 @@ Please read this page carefully, which includes precautions and instructions on #### One click automatic formatting of partitions, expansion, and automatic mounting of plugins, designed specifically for OPENWRT to simplify the tedious operation of partition mounting for OPENWRT. This plugin requires a lot of effort from Sirpdboy to create and test. Please do not delete the creator's information!! -## Version -- Latest update version number: V1.3.1 -- Update Date: March 26, 2025 -- Update content: -- Reorganize the partition expansion code and address some unreasonable areas. -- Add the format for the target partition, which can be specified as ext4, ntfs, Btrfs, or no format. -- When used as a root directory/or/overlay, it will be formatted in ext4 format. -- At present, the testing on X86 machines is completely normal, and it has not been tested on other routing devices. Please submit the hard disk partition status and error message if there are any issues. +### Update Date: January 14, 2026 + +- Latest updated version number: V2.0.2 +- Update: +- The newly upgraded JavaScript version supports the OpenWRT 25.12 version. +- Add a progress bar display function and support for more partition formats. +- Add more detailed reporting on log partition status. +- Currently, testing on X86 machines is completely normal, but it has not been tested on other routing devices. If there are any issues, please provide the hard disk partition details and error messages. + +### Update Date: March 26, 2025 +- Latest updated version number: V1.3.1 +- Update: +- Reorganize the partition expansion code to address some unreasonable aspects. +- Add the format option for the target partition, allowing users to specify formatting options such as ext4, NTFS, Btrfs, or no formatting. +- When used as the root directory or as /overlay, the partition will be automatically formatted as ext4. +- Currently, testing on X86 machines is completely normal, but it has not been tested on other routing devices. If there are any issues, please provide the hard disk partition information and error messages. @@ -68,12 +76,14 @@ Luci app parexp automatically obtains formatted partition expansion and automati ## interface -![screenshots](./doc/partexp0.png) - ![screenshots](./doc/partexp1.png) ![screenshots](./doc/partexp2.png) +![screenshots](./doc/partexp3.png) + +![screenshots](./doc/partexp4.png) + # My other project diff --git a/openwrt-packages/luci-app-partexp/README_CN.md b/openwrt-packages/luci-app-partexp/README_CN.md index ff706d6bc6..00e636b71c 100644 --- a/openwrt-packages/luci-app-partexp/README_CN.md +++ b/openwrt-packages/luci-app-partexp/README_CN.md @@ -15,15 +15,12 @@ ![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明1.jpg) - [luci-app-partexp](https://github.com/sirpdboy/luci-app-partexp) 一键自动格式化分区、扩容、自动挂载插件 - 请 **认真阅读完毕** 本页面,本页面包含注意事项和如何使用。 ## 功能说明: - #### 一键自动格式化分区、扩容、自动挂载插件,专为OPENWRT设计,简化OPENWRT在分区挂载上烦锁的操作。本插件是sirpdboy耗费大量精力制作测试,请勿删除制作者信息!! @@ -39,8 +36,17 @@ ## 版本 +### 更新日期:2026年1月14日 + +- 最新更新版本号: V2.0.2 +- 更新内容: +- 全新升级js版,支持openwrt25.12版本 。 +- 增加进度条显示功能和更多的分区格式。 +- 增加更详细的日志分区情况汇报。 +- 目前在X86的机器上测试完全正常,其它路由设备上未测试。有问题请提交硬盘分区情况和错误提示。 + +### 更新日期:2025年3月26日 - 最新更新版本号: V1.3.1 -- 更新日期:2025年3月26日 - 更新内容: - 重新整理分区扩容代码,解决一些不合理的地方。 - 加入对目标分区的格式,可以指定格式化为ext4,ntfs和Btrfs以及不格式化。 @@ -89,15 +95,15 @@ ![screenshots](./doc/partexp2.png) +![screenshots](./doc/partexp3.png) +![screenshots](./doc/partexp4.png) ## 使用与授权相关说明 - 本人开源的所有源码,任何引用需注明本处出处,如需修改二次发布必告之本人,未经许可不得做于任何商用用途。 - - # My other project - 路由安全看门狗 :https://github.com/sirpdboy/luci-app-watchdog diff --git a/openwrt-packages/luci-app-partexp/doc/partexp0.png b/openwrt-packages/luci-app-partexp/doc/partexp0.png index 17f46fcf1f..b5d50f353b 100644 Binary files a/openwrt-packages/luci-app-partexp/doc/partexp0.png and b/openwrt-packages/luci-app-partexp/doc/partexp0.png differ diff --git a/openwrt-packages/luci-app-partexp/doc/partexp1.png b/openwrt-packages/luci-app-partexp/doc/partexp1.png index 69ae604414..62c1bcd211 100644 Binary files a/openwrt-packages/luci-app-partexp/doc/partexp1.png and b/openwrt-packages/luci-app-partexp/doc/partexp1.png differ diff --git a/openwrt-packages/luci-app-partexp/doc/partexp2.png b/openwrt-packages/luci-app-partexp/doc/partexp2.png index 13450bd9b4..c096d1c71b 100644 Binary files a/openwrt-packages/luci-app-partexp/doc/partexp2.png and b/openwrt-packages/luci-app-partexp/doc/partexp2.png differ diff --git a/openwrt-packages/luci-app-partexp/doc/partexp3.png b/openwrt-packages/luci-app-partexp/doc/partexp3.png new file mode 100644 index 0000000000..6c7006cf2e Binary files /dev/null and b/openwrt-packages/luci-app-partexp/doc/partexp3.png differ diff --git a/openwrt-packages/luci-app-partexp/doc/partexp4.png b/openwrt-packages/luci-app-partexp/doc/partexp4.png new file mode 100644 index 0000000000..b5d50f353b Binary files /dev/null and b/openwrt-packages/luci-app-partexp/doc/partexp4.png differ diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua index 9c4121b524..8b17b0bef9 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua @@ -12,6 +12,7 @@ local trojan_type = {} local vmess_type = {} local vless_type = {} local hysteria2_type = {} +local xray_version = api.get_app_version("xray") if has_ss then local s = "shadowsocks-libev" table.insert(ss_type, s) @@ -38,6 +39,9 @@ if has_xray then table.insert(ss_type, s) table.insert(vmess_type, s) table.insert(vless_type, s) + if api.compare_versions(xray_version, ">=", "26.1.13") then + table.insert(hysteria2_type, s) + end end if has_hysteria2 then local s = "hysteria2" diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua index 92e712523d..b3a24812fc 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua @@ -27,6 +27,7 @@ local trojan_type = {} local vmess_type = {} local vless_type = {} local hysteria2_type = {} +local xray_version = api.get_app_version("xray") if has_ss then local s = "shadowsocks-libev" table.insert(ss_type, s) @@ -53,6 +54,9 @@ if has_xray then table.insert(ss_type, s) table.insert(vmess_type, s) table.insert(vless_type, s) + if api.compare_versions(xray_version, ">=", "26.1.13") then + table.insert(hysteria2_type, s) + end end if has_hysteria2 then local s = "hysteria2" diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index 95fb5b9773..293dcf32f5 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -40,6 +40,9 @@ o:value("socks", translate("Socks")) o:value("shadowsocks", translate("Shadowsocks")) o:value("trojan", translate("Trojan")) o:value("wireguard", translate("WireGuard")) +if api.compare_versions(xray_version, ">=", "26.1.13") then + o:value("hysteria2", translate("Hysteria2")) +end if api.compare_versions(xray_version, ">=", "1.8.12") then o:value("_balancing", translate("Balancing")) end @@ -399,6 +402,42 @@ o:value("", translate("Disable")) o:value("xtls-rprx-vision") o:depends({ [_n("protocol")] = "vless" }) +---- [[hysteria2]] +o = s:option(Value, _n("hysteria2_hop"), translate("Port hopping range")) +o.description = translate("Format as 1000:2000 or 1000-2000 Multiple groups are separated by commas (,).") +o:depends({ [_n("protocol")] = "hysteria2" }) + +o = s:option(Value, _n("hysteria2_hop_interval"), translate("Hop Interval"), translate("Example:") .. "30s (≥5s)") +o.placeholder = "30s" +o.default = "30s" +o:depends({ [_n("protocol")] = "hysteria2" }) + +o = s:option(Value, _n("hysteria2_up_mbps"), translate("Max upload Mbps")) +o:depends({ [_n("protocol")] = "hysteria2" }) + +o = s:option(Value, _n("hysteria2_down_mbps"), translate("Max download Mbps")) +o:depends({ [_n("protocol")] = "hysteria2" }) + +o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type")) +o:value("", translate("Disable")) +o:value("salamander") +o:depends({ [_n("protocol")] = "hysteria2" }) + +o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password")) +o:depends({ [_n("protocol")] = "hysteria2" }) + +o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password")) +o.password = true +o:depends({ [_n("protocol")] = "hysteria2"}) + +o = s:option(Value, _n("hysteria2_idle_timeout"), translate("Idle Timeout"), translate("Example:") .. "30s (4s-120s)") +o:depends({ [_n("protocol")] = "hysteria2"}) + +o = s:option(Flag, _n("hysteria2_disable_mtu_discovery"), translate("Disable MTU detection")) +o.default = "0" +o:depends({ [_n("protocol")] = "hysteria2"}) +---- [[hysteria2 end]] + o = s:option(Flag, _n("tls"), translate("TLS")) o.default = 0 o:depends({ [_n("protocol")] = "vmess" }) @@ -426,6 +465,7 @@ o:value("http/1.1") o:value("h2,http/1.1") o:value("h3,h2,http/1.1") o:depends({ [_n("tls")] = true, [_n("reality")] = false }) +o:depends({ [_n("protocol")] = "hysteria2" }) -- o = s:option(Value, _n("minversion"), translate("minversion")) -- o.default = "1.3" @@ -434,10 +474,12 @@ o:depends({ [_n("tls")] = true, [_n("reality")] = false }) o = s:option(Value, _n("tls_serverName"), translate("Domain")) o:depends({ [_n("tls")] = true }) +o:depends({ [_n("protocol")] = "hysteria2" }) o = s:option(Flag, _n("tls_allowInsecure"), translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped.")) o.default = "0" o:depends({ [_n("tls")] = true, [_n("reality")] = false }) +o:depends({ [_n("protocol")] = "hysteria2" }) o = s:option(Value, _n("tls_chain_fingerprint"), translate("TLS Chain Fingerprint (SHA256)"), translate("Once set, connects only when the server’s chain fingerprint matches.")) o:depends({ [_n("tls")] = true, [_n("reality")] = false }) @@ -445,6 +487,7 @@ o:depends({ [_n("tls")] = true, [_n("reality")] = false }) o = s:option(Flag, _n("ech"), translate("ECH")) o.default = "0" o:depends({ [_n("tls")] = true, [_n("flow")] = "", [_n("reality")] = false }) +o:depends({ [_n("protocol")] = "hysteria2" }) o = s:option(TextValue, _n("ech_config"), translate("ECH Config")) o.default = "" @@ -734,11 +777,6 @@ o:depends({ [_n("mux")] = true }) o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open")) o.default = 0 -o:depends({ [_n("protocol")] = "vmess" }) -o:depends({ [_n("protocol")] = "vless" }) -o:depends({ [_n("protocol")] = "socks" }) -o:depends({ [_n("protocol")] = "shadowsocks" }) -o:depends({ [_n("protocol")] = "trojan" }) --[[tcpMptcp]] o = s:option(Flag, _n("tcpMptcp"), "tcpMptcp", translate("Enable Multipath TCP, need to be enabled in both server and client configuration.")) @@ -779,7 +817,8 @@ for k, v in pairs(nodes_table) do end for i, v in ipairs(s.fields[_n("protocol")].keylist) do - if not v:find("_") then + if not v:find("_") and v ~= "hysteria2" then + s.fields[_n("tcp_fast_open")]:depends({ [_n("protocol")] = v }) s.fields[_n("tcpMptcp")]:depends({ [_n("protocol")] = v }) s.fields[_n("chain_proxy")]:depends({ [_n("protocol")] = v }) end diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua index 85a29cffbd..384f9c74ac 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/api.lua @@ -1159,7 +1159,7 @@ function get_version() if not version or #version == 0 then version = sys.exec("apk list luci-app-passwall 2>/dev/null | awk '/installed/ {print $1}' | cut -d'-' -f4-") end - return (version or ""):gsub("\n", "") + return (version or ""):gsub("\n", ""):match("^([^-]+)") end function to_check_self() diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua index 7b2bc1b2b3..56e3ad6dcd 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -130,6 +130,12 @@ function gen_outbound(flag, node, tag, proxy_table) node.wireguard_reserved = #bytes > 0 and bytes or nil end + if node.protocol == "hysteria2" then + node.protocol = "hysteria" + node.transport = "hysteria" + node.stream_security = "tls" + end + result = { _id = node_id, _flag = flag, @@ -143,7 +149,7 @@ function gen_outbound(flag, node, tag, proxy_table) xudpConcurrency = (node.mux == "1" and ((node.xudp_concurrency) and tonumber(node.xudp_concurrency) or 8)) or nil } or nil, -- 底层传输配置 - streamSettings = (node.streamSettings or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan") and { + streamSettings = (node.streamSettings or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria") and { sockopt = { mark = 255, tcpFastOpen = (node.tcp_fast_open == "1") and true or nil, @@ -257,6 +263,36 @@ function gen_outbound(flag, node, tag, proxy_table) return next(extra_tbl) ~= nil and extra_tbl or nil end)() } or nil, + hysteriaSettings = (node.transport == "hysteria") and { + version = 2, + auth = node.hysteria2_auth_password, + up = (node.hysteria2_up_mbps and tonumber(node.hysteria2_up_mbps)) and tonumber(node.hysteria2_up_mbps) .. "mbps" or nil, + down = (node.hysteria2_down_mbps and tonumber(node.hysteria2_down_mbps)) and tonumber(node.hysteria2_down_mbps) .. "mbps" or nil, + udphop = (node.hysteria2_hop) and { + port = string.gsub(node.hysteria2_hop, ":", "-"), + interval = (function() + local v = tonumber((node.hysteria2_hop_interval or "30s"):match("^%d+")) + return (v and v >= 5) and (v .. "s") or "30s" + end)() + } or nil, + maxIdleTimeout = (function() + local timeoutStr = tostring(node.hysteria2_idle_timeout or "") + local timeout = tonumber(timeoutStr:match("^%d+")) + if timeout and timeout >= 4 and timeout <= 120 then + return timeout + end + return 30 + end)(), + disablePathMTUDiscovery = (node.hysteria2_disable_mtu_discovery) and true or false + } or nil, + udpmasks = (node.transport == "hysteria" and node.hysteria2_obfs_type and node.hysteria2_obfs_type ~= "") and { + { + type = node.hysteria2_obfs_type, + settings = node.hysteria2_obfs_password and { + password = node.hysteria2_obfs_password + } or nil + } + } or nil } or nil, settings = { vnext = (node.protocol == "vmess" or node.protocol == "vless") and { @@ -295,7 +331,7 @@ function gen_outbound(flag, node, tag, proxy_table) } or nil } } or nil, - address = (node.protocol == "wireguard" and node.wireguard_local_address) and node.wireguard_local_address or nil, + address = (node.protocol == "wireguard" and node.wireguard_local_address) or (node.protocol == "hysteria" and node.address) or nil, secretKey = (node.protocol == "wireguard") and node.wireguard_secret_key or nil, peers = (node.protocol == "wireguard") and { { @@ -306,7 +342,9 @@ function gen_outbound(flag, node, tag, proxy_table) } } or nil, mtu = (node.protocol == "wireguard" and node.wireguard_mtu) and tonumber(node.wireguard_mtu) or nil, - reserved = (node.protocol == "wireguard" and node.wireguard_reserved) and node.wireguard_reserved or nil + reserved = (node.protocol == "wireguard" and node.wireguard_reserved) and node.wireguard_reserved or nil, + port = (node.protocol == "hysteria" and node.port) and tonumber(node.port) or nil, + version = node.protocol == "hysteria" and 2 or nil } } @@ -1634,7 +1672,8 @@ function gen_config(var) if not value["_flag_proxy_tag"] and value["_id"] and s and not no_run and ((s.vnext and s.vnext[1] and s.vnext[1].address and s.vnext[1].port) or (s.servers and s.servers[1] and s.servers[1].address and s.servers[1].port) or - (s.peers and s.peers[1] and s.peers[1].endpoint)) then + (s.peers and s.peers[1] and s.peers[1].endpoint) or + (s.address and s.port)) then sys.call(string.format("echo '%s' >> %s", value["_id"], api.TMP_PATH .. "/direct_node_list")) end for k, v in pairs(config.outbounds[index]) do diff --git a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/status.htm b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/status.htm index e8d76ec4ce..f2915cbd9a 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/status.htm +++ b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/status.htm @@ -138,7 +138,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md -
+
@@ -150,7 +150,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
-
+
@@ -162,7 +162,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
-
+
@@ -174,7 +174,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
-
+
diff --git a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_list/link_share_man.htm b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_list/link_share_man.htm index 9ffcfb7c35..7cbbe81036 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_list/link_share_man.htm +++ b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/node_list/link_share_man.htm @@ -25,7 +25,7 @@ local ss_type = get_core("ss_type", {{has_ss,"shadowsocks-libev"},{has_ss_rust," local trojan_type = get_core("trojan_type", {{has_trojan_plus,"trojan-plus"},{has_singbox,"sing-box"},{has_xray,"xray"}}) local vmess_type = get_core("vmess_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) local vless_type = get_core("vless_type", {{has_xray,"xray"},{has_singbox,"sing-box"}}) -local hysteria2_type = get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"}}) +local hysteria2_type = get_core("hysteria2_type", {{has_hysteria2,"hysteria2"},{has_singbox,"sing-box"}, {has_xray,"xray"}}) -%>