From 0b90dc2bed39b50a457b47012ac2530e3234271c Mon Sep 17 00:00:00 2001 From: Emma Hermione <80580396+EmmaHermione@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:30 +0800 Subject: [PATCH] feat(sub): map shadowsocks TCP HTTP headers to Mihomo's obfs plugin (#6838) A shadowsocks inbound with a TCP HTTP header was dropped from Mihomo subscriptions, although Mihomo's simple-obfs speaks the same wire format (#6735). Such a node is now emitted with plugin: obfs, mode http and the request Host header. Mihomo always requests GET /, and xray-core rejects a request whose path is not one of the header's paths, so only headers that accept / map; others stay dropped, as do non-shadowsocks protocols and the legacy Clash endpoint. --- internal/sub/clash_external_quota_test.go | 14 +- .../sub/clash_external_shadowsocks_test.go | 2 +- internal/sub/clash_service.go | 68 +++++++++- internal/sub/clash_service_test.go | 126 ++++++++++++++++++ 4 files changed, 199 insertions(+), 11 deletions(-) diff --git a/internal/sub/clash_external_quota_test.go b/internal/sub/clash_external_quota_test.go index 5b6e148cd..427b418df 100644 --- a/internal/sub/clash_external_quota_test.go +++ b/internal/sub/clash_external_quota_test.go @@ -15,12 +15,12 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" ) -// base64("aes-256-gcm:clientpw"), the SIP002 userinfo both spellings share. The -// panel emits this node as `plugin=obfs-local;obfs=http`, and Clash has no way -// to represent it, so both the inbound path and the link path drop it. -const clashDroppedExternalLink = "ss://YWVzLTI1Ni1nY206Y2xpZW50cHc@198.51.100.9:8443?type=tcp&headerType=http&host=test#obfs" - -const tcpObfsStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}` +// A shadowsocks node over kcp has no Clash form, so both the inbound path and the link +// path must drop it; the userinfo is base64("aes-256-gcm:clientpw"). +const ( + clashDroppedExternalLink = "ss://YWVzLTI1Ni1nY206Y2xpZW50cHc@198.51.100.9:8443?type=kcp#unsupported" + kcpShadowsocksStream = `{"network":"kcp","security":"none"}` +) func clashSubRouter(t *testing.T) *gin.Engine { t.Helper() @@ -95,7 +95,7 @@ func TestClashAllUnrepresentableNodesAnswerAlike(t *testing.T) { t.Run(source, func(t *testing.T) { initSubDB(t) if source == "inbound" { - seedSubInbound(t, "clash-parity", "obfs", 10001, 1, tcpObfsStream) + seedSubInbound(t, "clash-parity", "obfs", 10001, 1, kcpShadowsocksStream) } else { rec := &model.ClientRecord{Email: "B@e", SubID: "clash-parity", UUID: "22222222-2222-4222-8222-222222222222", Enable: true} if err := database.GetDB().Create(rec).Error; err != nil { diff --git a/internal/sub/clash_external_shadowsocks_test.go b/internal/sub/clash_external_shadowsocks_test.go index 0fd5f02f7..21fa64fd2 100644 --- a/internal/sub/clash_external_shadowsocks_test.go +++ b/internal/sub/clash_external_shadowsocks_test.go @@ -30,7 +30,7 @@ func TestClashExternalShadowsocksMatchesInboundPath(t *testing.T) { name: "tcp http header", stream: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}`, link: "ss://" + userinfo + "@203.0.113.1:8443?type=tcp&headerType=http&host=test#ss", - wantDropped: true, + wantDropped: false, }, { name: "tls", diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 8af51c57d..697384c45 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -231,6 +231,10 @@ func legacyClashProxy(proxy map[string]any) map[string]any { if (network != "" && network != "tcp") || tls || cipher == "" { return nil } + // Legacy endpoints directly block obfs nodes. + if plugin, _ := proxy["plugin"].(string); plugin == "obfs" { + return nil + } fields = []string{"name", "type", "server", "port", "password", "cipher", "udp", "plugin", "plugin-opts"} default: return nil @@ -434,9 +438,6 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, "port": inbound.Port, "udp": true, } - if !s.applyTransport(proxy, network, stream) { - return nil - } switch inbound.Protocol { case model.VMESS: @@ -479,6 +480,10 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, return nil } + if !s.applyTransport(proxy, network, stream) { + return nil + } + security, _ := stream["security"].(string) if !s.applySecurity(proxy, security, stream) { return nil @@ -1074,6 +1079,19 @@ func (s *SubClashService) applyTransport(proxy map[string]any, network string, s if header != nil { typeStr, _ := header["type"].(string) if typeStr != "" && typeStr != "none" { + // Mihomo's simple-obfs always sends GET /, and xray-core rejects a request whose + // path is not one of the header's paths, so only a "/" header maps to obfs. + if proxy["type"] == "ss" && typeStr == "http" && tcpHeaderAcceptsRootPath(header) { + proxy["plugin"] = "obfs" + opts := map[string]any{ + "mode": "http", + } + if host := extractTCPHeaderHost(header); host != "" { + opts["host"] = host + } + proxy["plugin-opts"] = opts + return true + } return false } } @@ -1149,6 +1167,50 @@ func (s *SubClashService) applyTransport(proxy map[string]any, network string, s } } +// tcpHeaderAcceptsRootPath reports whether the inbound's HTTP header accepts "/"; xray-core +// defaults an absent request path to "/". +func tcpHeaderAcceptsRootPath(header map[string]any) bool { + request, _ := header["request"].(map[string]any) + paths, _ := request["path"].([]any) + if len(paths) == 0 { + return true + } + for _, p := range paths { + if p == "/" { + return true + } + } + return false +} + +func extractTCPHeaderHost(header map[string]any) string { + request, _ := header["request"].(map[string]any) + reqHeaders, _ := request["headers"].(map[string]any) + return findHostInHeaders(reqHeaders) +} + +func findHostInHeaders(headers map[string]any) string { + for k, v := range headers { + if strings.EqualFold(k, "host") { + switch val := v.(type) { + case string: + return strings.TrimSpace(val) + case []string: + if len(val) > 0 { + return strings.TrimSpace(val[0]) + } + case []any: + if len(val) > 0 { + if h, ok := val[0].(string); ok { + return strings.TrimSpace(h) + } + } + } + } + } + return "" +} + func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool { switch security { case "", "none": diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 36b364352..0afe2e7bb 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -226,6 +226,132 @@ func TestApplyTransport_TCPHeader(t *testing.T) { if svc.applyTransport(map[string]any{}, "tcp", httpStream) { t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected") } + + testCases := []struct { + name string + proxyType string + stream map[string]any + wantAccepted bool + wantPlugin string + wantHost string + }{ + { + name: "Actual 3x-ui Panel Inbound Data Structure (Including Empty Response Headers)", + proxyType: "ss", + stream: map[string]any{ + "tcpSettings": map[string]any{ + "header": map[string]any{ + "type": "http", + "request": map[string]any{ + "version": "1.1", + "method": "GET", + "path": []any{"/"}, + "headers": map[string]any{ + "Host": []any{"gateway.icloud.com"}, + }, + }, + "response": map[string]any{ + "version": "1.1", + "status": "200", + "reason": "OK", + "headers": map[string]any{}, + }, + }, + }, + }, + wantAccepted: true, + wantPlugin: "obfs", + wantHost: "gateway.icloud.com", + }, + { + name: "Host must be a lowercase plain string", + proxyType: "ss", + stream: map[string]any{ + "tcpSettings": map[string]any{ + "header": map[string]any{ + "type": "http", + "request": map[string]any{ + "headers": map[string]any{ + "host": "single.example.com", + }, + }, + }, + }, + }, + wantAccepted: true, + wantPlugin: "obfs", + wantHost: "single.example.com", + }, + { + name: "Non-Shadowsocks protocols must be rejected when paired with TCP HTTP obfuscation", + proxyType: "vmess", + stream: map[string]any{ + "tcpSettings": map[string]any{ + "header": map[string]any{ + "type": "http", + "request": map[string]any{ + "headers": map[string]any{ + "Host": []any{"vmess.example.com"}, + }, + }, + }, + }, + }, + wantAccepted: false, + }, + { + name: "A header path other than / is dropped: obfs always requests /", + proxyType: "ss", + stream: map[string]any{ + "tcpSettings": map[string]any{ + "header": map[string]any{ + "type": "http", + "request": map[string]any{ + "path": []any{"/download"}, + "headers": map[string]any{"Host": []any{"cdn.example.com"}}, + }, + }, + }, + }, + wantAccepted: false, + }, + { + name: "Host comes from the request headers only", + proxyType: "ss", + stream: map[string]any{ + "tcpSettings": map[string]any{ + "header": map[string]any{ + "type": "http", + "response": map[string]any{"headers": map[string]any{"Host": []any{"response.example.com"}}}, + }, + }, + }, + wantAccepted: true, + wantPlugin: "obfs", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + proxy := map[string]any{"type": tc.proxyType} + got := svc.applyTransport(proxy, "tcp", tc.stream) + if got != tc.wantAccepted { + t.Fatalf("expected accepted=%v, got=%v", tc.wantAccepted, got) + } + if tc.wantAccepted { + if proxy["plugin"] != tc.wantPlugin { + t.Fatalf("expected plugin=%q, got=%v", tc.wantPlugin, proxy["plugin"]) + } + opts, _ := proxy["plugin-opts"].(map[string]any) + if opts["mode"] != "http" { + t.Fatalf("expected mode=http, got=%v", opts["mode"]) + } + if host, _ := opts["host"].(string); host != tc.wantHost { + t.Fatalf("expected host=%q, got=%v", tc.wantHost, opts["host"]) + } + } + }) + } } func TestApplyTransport_XHTTP(t *testing.T) {