From 041476a31747e2a95ac1c0544271fe647f5bf4cb Mon Sep 17 00:00:00 2001 From: Maksim Alekseev <31767561+beehunt9r@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:15:28 +0300 Subject: [PATCH] feat(sub): Add XHTTP session field compatibility in share links and subscriptions (#5929) * :sparkles: Add sessionKey and sessionPlacement compatability for previous clients * :sparkles: Add sessionKey and sessionPlacement compatability for previous clients on backend --- frontend/src/lib/xray/inbound-link.ts | 12 ++++- frontend/src/test/inbound-link.test.ts | 70 ++++++++++++++++++++++++++ internal/sub/service.go | 9 ++++ internal/sub/service_test.go | 30 +++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 0a078c42c..4e5d3dd4d 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -42,8 +42,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string { // Pull the bidirectional SplitHTTPConfig fields out of xhttp into a // compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts, // scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client -// reading the share link wouldn't honor them. Mirrors the legacy -// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up. +// reading the share link wouldn't honor them. function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record | null { if (!xhttp) return null; const extra: Record = {}; @@ -85,6 +84,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record 0 && v !== coreDefaults[k]) extra[k] = v; } + // xray-core #6258 renamed these fields, but older clients still read the + // legacy names from share-link extra. Emit both names so one link works + // across old and new clients while the stored panel config stays canonical. + if (typeof extra.sessionIDPlacement === 'string') { + extra.sessionPlacement = extra.sessionIDPlacement; + } + if (typeof extra.sessionIDKey === 'string') { + extra.sessionKey = extra.sessionIDKey; + } // Headers on the wire are a record; emit them as a map upstream's // SplitHTTPConfig.headers expects, dropping Host (already on the URL). diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 97f56f046..03fb915fe 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -745,3 +745,73 @@ describe('genVlessLink flow gating (#5322)', () => { expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision'); }); }); + +describe('genVlessLink XHTTP extra compatibility', () => { + it('emits both sessionID and legacy session keys in XHTTP extra', () => { + const typed = InboundSchema.parse({ + id: 1, + up: 0, + down: 0, + total: 0, + remark: 'xhttp-session', + enable: true, + expiryTime: 0, + listen: '', + port: 443, + tag: 'inbound-vless-xhttp', + sniffing: { + enabled: false, + destOverride: [], + metadataOnly: false, + routeOnly: false, + ipsExcluded: [], + domainsExcluded: [], + }, + protocol: 'vless', + settings: { + clients: [ + { + id: '11111111-2222-3333-4444-555555555555', + email: 'a@example.test', + flow: '', + limitIp: 0, + totalGB: 0, + expiryTime: 0, + enable: true, + tgId: 0, + subId: 's1', + comment: '', + reset: 0, + }, + ], + decryption: 'none', + encryption: 'none', + fallbacks: [], + }, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { + path: '/sp', + host: 'edge.example.test', + mode: 'auto', + sessionIDPlacement: 'header', + sessionIDKey: 'X-Session', + }, + }, + }); + + const link = genVlessLink({ + inbound: typed, + address: 'example.test', + port: 443, + clientId: '11111111-2222-3333-4444-555555555555', + }); + const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record; + + expect(extra.sessionIDPlacement).toBe('header'); + expect(extra.sessionIDKey).toBe('X-Session'); + expect(extra.sessionPlacement).toBe('header'); + expect(extra.sessionKey).toBe('X-Session'); + }); +}); diff --git a/internal/sub/service.go b/internal/sub/service.go index 983cec894..c43974fff 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2017,6 +2017,15 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { } } } + // Older clients still read the pre-#6258 names from the subscription + // extra JSON. Emit aliases after lifting legacy inputs so both old and + // new clients can consume the same link. + if v, ok := extra["sessionIDPlacement"].(string); ok && len(v) > 0 { + extra["sessionPlacement"] = v + } + if v, ok := extra["sessionIDKey"].(string); ok && len(v) > 0 { + extra["sessionKey"] = v + } for _, field := range []string{"uplinkChunkSize"} { if v, ok := nonZeroShareValue(xhttp[field]); ok { diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 7754cd4f3..043f539e2 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -335,6 +335,8 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) { "mode": "packet-up", "xPaddingBytes": "100-1000", "uplinkHTTPMethod": "GET", + "sessionIDPlacement": "header", + "sessionIDKey": "X-Session", "uplinkChunkSize": float64(4096), "noGRPCHeader": true, "scMinPostsIntervalMs": "20-40", @@ -375,6 +377,16 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) { if extra["mode"] != "packet-up" { t.Fatalf("extra[mode] = %#v, want packet-up", extra["mode"]) } + for key, want := range map[string]string{ + "sessionIDPlacement": "header", + "sessionIDKey": "X-Session", + "sessionPlacement": "header", + "sessionKey": "X-Session", + } { + if extra[key] != want { + t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra) + } + } headers, ok := extra["headers"].(map[string]any) if !ok { @@ -388,6 +400,24 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) { } } +func TestBuildXhttpExtra_LegacySessionFieldsEmitBothNames(t *testing.T) { + extra := buildXhttpExtra(map[string]any{ + "sessionPlacement": "query", + "sessionKey": "sess", + }) + + for key, want := range map[string]string{ + "sessionIDPlacement": "query", + "sessionIDKey": "sess", + "sessionPlacement": "query", + "sessionKey": "sess", + } { + if extra[key] != want { + t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra) + } + } +} + func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) { extra := buildXhttpExtra(map[string]any{ "uplinkHTTPMethod": "",