From 6bd1b8676a3ae6c5a7e030f3f403a8efed7e4d9a Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:04:35 +0530 Subject: [PATCH] fix(link): read the XHTTP fields a vmess link carries inline (#6808) A vmess link has no extra blob: the sub emitter writes the XHTTP extra fields (session/seq/uplink placement, padding obfs, xmux, ...) at the top level of the vmess JSON. The Go importer only read xPaddingBytes, scMaxEachPostBytes and scMinPostsIntervalMs from there, so a vmess XHTTP inbound with, for example, sessionIDPlacement=header or xPaddingObfsMode came back from an outbound subscription or a client external link as an outbound that cannot connect. Read the same keys the TS importer already reads from vmess JSON, including the pre-rename sessionPlacement/sessionKey aliases. --- internal/util/link/outbound.go | 25 ++++++++++- internal/util/link/outbound_helpers_test.go | 46 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 9620a28aa..b29af0f2f 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -185,12 +185,18 @@ func parseVmess(link string) (*ParseResult, error) { if m := getString(j, "mode", ""); m != "" { xh["mode"] = m } - // xhttp advanced keys are passed through if present in the json - for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} { + for _, k := range vmessXhttpKeys { if v, ok := j[k]; ok { xh[k] = v } } + for legacy, renamed := range xhttpLegacyAliases { + if _, ok := xh[renamed]; !ok { + if v, ok := j[legacy].(string); ok && v != "" { + xh[renamed] = v + } + } + } case "tcp": if getString(j, "type", "") == "http" { stream["tcpSettings"] = map[string]any{ @@ -258,6 +264,21 @@ func parseVmess(link string) (*ParseResult, error) { return &ParseResult{Outbound: ob, Identity: identity}, nil } +// vmessXhttpKeys are the XHTTP fields a vmess link carries inline, where vless/trojan put them in extra. +var vmessXhttpKeys = []string{ + "xPaddingBytes", "xPaddingObfsMode", "xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod", + "sessionIDPlacement", "sessionIDKey", "sessionIDTable", "sessionIDLength", + "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "uplinkHTTPMethod", "uplinkChunkSize", + "scMaxEachPostBytes", "scMinPostsIntervalMs", "scMaxBufferedPosts", "scStreamUpServerSecs", + "serverMaxHeaderBytes", "noSSEHeader", "noGRPCHeader", "xmux", "downloadSettings", "headers", +} + +// xhttpLegacyAliases maps the pre-rename session keys older links carry onto the current ones. +var xhttpLegacyAliases = map[string]string{ + "sessionPlacement": "sessionIDPlacement", + "sessionKey": "sessionIDKey", +} + func vmessIdentity(j map[string]any) string { // Remove ps (remark) for identity core := map[string]any{} diff --git a/internal/util/link/outbound_helpers_test.go b/internal/util/link/outbound_helpers_test.go index bc9ca7fbd..3ff90d275 100644 --- a/internal/util/link/outbound_helpers_test.go +++ b/internal/util/link/outbound_helpers_test.go @@ -200,6 +200,52 @@ func TestParse_XhttpExtraAndSnakeCaseFields(t *testing.T) { } } +func TestParse_VmessXhttpInlineFields(t *testing.T) { + // The vmess emitter writes the XHTTP extra fields at the top level of the link JSON. + cases := []struct { + name string + extra string + want map[string]any + }{ + { + name: "session placement and padding obfs", + extra: `"sessionIDPlacement":"header","sessionIDKey":"X-Sid","sessionPlacement":"header","sessionKey":"X-Sid","xPaddingObfsMode":true,"xPaddingKey":"pk","xPaddingHeader":"X-Pad","xPaddingPlacement":"header","xPaddingMethod":"tokenish"`, + want: map[string]any{ + "sessionIDPlacement": "header", "sessionIDKey": "X-Sid", "xPaddingObfsMode": true, + "xPaddingKey": "pk", "xPaddingHeader": "X-Pad", "xPaddingPlacement": "header", "xPaddingMethod": "tokenish", + }, + }, + { + name: "uplink and xmux", + extra: `"uplinkDataPlacement":"cookie","uplinkDataKey":"d","uplinkHTTPMethod":"PUT","uplinkChunkSize":4096,"xmux":{"maxConcurrency":"16-32"}`, + want: map[string]any{ + "uplinkDataPlacement": "cookie", "uplinkDataKey": "d", "uplinkHTTPMethod": "PUT", + "uplinkChunkSize": float64(4096), "xmux": map[string]any{"maxConcurrency": "16-32"}, + }, + }, + { + name: "legacy session keys", + extra: `"sessionPlacement":"query","sessionKey":"sid"`, + want: map[string]any{"sessionIDPlacement": "query", "sessionIDKey": "sid"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"xhttp","path":"/xh","mode":"packet-up","tls":"none",` + c.extra + `}` + res, err := ParseLink("vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))) + if err != nil { + t.Fatalf("parse: %v", err) + } + xh := streamSub(t, res, "xhttpSettings") + for k, want := range c.want { + if !reflect.DeepEqual(xh[k], want) { + t.Errorf("xhttpSettings.%s = %#v, want %#v", k, xh[k], want) + } + } + }) + } +} + func TestParse_VmessWSPathWithoutHostKey(t *testing.T) { inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))