Browse Source

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.
Jay Hemnani 10 hours ago
parent
commit
6bd1b8676a
2 changed files with 69 additions and 2 deletions
  1. 23 2
      internal/util/link/outbound.go
  2. 46 0
      internal/util/link/outbound_helpers_test.go

+ 23 - 2
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{}

+ 46 - 0
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))