mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 16:45:03 +03:00
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.
This commit is contained in:
@@ -15,12 +15,12 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
// base64("aes-256-gcm:clientpw"), the SIP002 userinfo both spellings share. The
|
// A shadowsocks node over kcp has no Clash form, so both the inbound path and the link
|
||||||
// panel emits this node as `plugin=obfs-local;obfs=http`, and Clash has no way
|
// path must drop it; the userinfo is base64("aes-256-gcm:clientpw").
|
||||||
// to represent it, so both the inbound path and the link path drop it.
|
const (
|
||||||
const clashDroppedExternalLink = "ss://YWVzLTI1Ni1nY206Y2xpZW50cHc@198.51.100.9:8443?type=tcp&headerType=http&host=test#obfs"
|
clashDroppedExternalLink = "ss://YWVzLTI1Ni1nY206Y2xpZW50cHc@198.51.100.9:8443?type=kcp#unsupported"
|
||||||
|
kcpShadowsocksStream = `{"network":"kcp","security":"none"}`
|
||||||
const tcpObfsStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}`
|
)
|
||||||
|
|
||||||
func clashSubRouter(t *testing.T) *gin.Engine {
|
func clashSubRouter(t *testing.T) *gin.Engine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -95,7 +95,7 @@ func TestClashAllUnrepresentableNodesAnswerAlike(t *testing.T) {
|
|||||||
t.Run(source, func(t *testing.T) {
|
t.Run(source, func(t *testing.T) {
|
||||||
initSubDB(t)
|
initSubDB(t)
|
||||||
if source == "inbound" {
|
if source == "inbound" {
|
||||||
seedSubInbound(t, "clash-parity", "obfs", 10001, 1, tcpObfsStream)
|
seedSubInbound(t, "clash-parity", "obfs", 10001, 1, kcpShadowsocksStream)
|
||||||
} else {
|
} else {
|
||||||
rec := &model.ClientRecord{Email: "B@e", SubID: "clash-parity", UUID: "22222222-2222-4222-8222-222222222222", Enable: true}
|
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 {
|
if err := database.GetDB().Create(rec).Error; err != nil {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestClashExternalShadowsocksMatchesInboundPath(t *testing.T) {
|
|||||||
name: "tcp http header",
|
name: "tcp http header",
|
||||||
stream: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}`,
|
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",
|
link: "ss://" + userinfo + "@203.0.113.1:8443?type=tcp&headerType=http&host=test#ss",
|
||||||
wantDropped: true,
|
wantDropped: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tls",
|
name: "tls",
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ func legacyClashProxy(proxy map[string]any) map[string]any {
|
|||||||
if (network != "" && network != "tcp") || tls || cipher == "" {
|
if (network != "" && network != "tcp") || tls || cipher == "" {
|
||||||
return nil
|
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"}
|
fields = []string{"name", "type", "server", "port", "password", "cipher", "udp", "plugin", "plugin-opts"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -434,9 +438,6 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
|||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
"udp": true,
|
"udp": true,
|
||||||
}
|
}
|
||||||
if !s.applyTransport(proxy, network, stream) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch inbound.Protocol {
|
switch inbound.Protocol {
|
||||||
case model.VMESS:
|
case model.VMESS:
|
||||||
@@ -479,6 +480,10 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.applyTransport(proxy, network, stream) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
if !s.applySecurity(proxy, security, stream) {
|
if !s.applySecurity(proxy, security, stream) {
|
||||||
return nil
|
return nil
|
||||||
@@ -1074,6 +1079,19 @@ func (s *SubClashService) applyTransport(proxy map[string]any, network string, s
|
|||||||
if header != nil {
|
if header != nil {
|
||||||
typeStr, _ := header["type"].(string)
|
typeStr, _ := header["type"].(string)
|
||||||
if typeStr != "" && typeStr != "none" {
|
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
|
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 {
|
func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
|
||||||
switch security {
|
switch security {
|
||||||
case "", "none":
|
case "", "none":
|
||||||
|
|||||||
@@ -226,6 +226,132 @@ func TestApplyTransport_TCPHeader(t *testing.T) {
|
|||||||
if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
|
if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
|
||||||
t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected")
|
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) {
|
func TestApplyTransport_XHTTP(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user