From bd60e770f47c5aeaf3162485d2b536daabe9a71f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 24 Jun 2026 14:22:25 +0200 Subject: [PATCH] fix(outbound): preserve custom headers for HTTP outbounds (#5519) The Outbounds form routed HTTP through the SOCKS-shared simpleAuth adapter, which only knew address/port/user/pass, so xray's top-level settings.headers was dropped on both load and save. Opening and re-saving an HTTP outbound destroyed its headers. Add headers to the HTTP wire/form schemas, round-trip it via dedicated httpFromWire/httpToWire helpers, and expose a HeaderMapEditor in the form. Only settings-level headers round-trip; xray-core ignores per-server headers. --- .../src/lib/xray/outbound-form-adapter.ts | 33 +++++++++++++++++-- .../pages/xray/outbounds/protocols/http.tsx | 5 +++ frontend/src/schemas/forms/outbound-form.ts | 1 + .../src/schemas/protocols/outbound/http.ts | 1 + .../src/test/outbound-form-adapter.test.ts | 23 +++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index e048d7300..4208531e0 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -8,6 +8,7 @@ import type { DnsRuleForm, FreedomFinalRuleForm, FreedomOutboundFormSettings, + HttpOutboundFormSettings, HysteriaOutboundFormSettings, LoopbackOutboundFormSettings, MuxForm, @@ -178,6 +179,26 @@ function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettin }; } +function stringRecordFromWire(raw: unknown): Record { + const obj = asObject(raw); + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (typeof v === 'string') out[k] = v; + } + return out; +} + +// HTTP outbound reuses the SOCKS server/user shape but also carries xray's +// top-level `settings.headers` (HTTPClientConfig.Headers), the CONNECT +// headers sent to the upstream proxy. xray ignores per-server `headers`, +// so only the settings-level map round-trips (issue #5519). +function httpFromWire(raw: Raw): HttpOutboundFormSettings { + return { + ...simpleAuthFromWire(raw, 8080), + headers: stringRecordFromWire(raw.headers), + }; +} + function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings { const secretKey = asString(raw.secretKey); const pubKey = secretKey.length > 0 @@ -395,7 +416,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break; case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break; case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break; - case 'http': typed = { protocol: 'http', settings: simpleAuthFromWire(settings, 8080) }; break; + case 'http': typed = { protocol: 'http', settings: httpFromWire(settings) }; break; case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break; case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break; case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break; @@ -489,6 +510,14 @@ function simpleAuthToWire(s: SimpleAuthFormSettings) { }; } +function httpToWire(s: HttpOutboundFormSettings): Raw { + const wire: Raw = simpleAuthToWire(s); + if (s.headers && Object.keys(s.headers).length > 0) { + wire.headers = s.headers; + } + return wire; +} + function wireguardToWire(s: WireguardOutboundFormSettings) { return { mtu: s.mtu || undefined, @@ -629,7 +658,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun case 'trojan': settings = trojanToWire(values.settings); break; case 'shadowsocks': settings = shadowsocksToWire(values.settings); break; case 'socks': settings = simpleAuthToWire(values.settings); break; - case 'http': settings = simpleAuthToWire(values.settings); break; + case 'http': settings = httpToWire(values.settings); break; case 'wireguard': settings = wireguardToWire(values.settings); break; case 'hysteria': settings = hysteriaToWire(values.settings); break; case 'freedom': settings = freedomToWire(values.settings); break; diff --git a/frontend/src/pages/xray/outbounds/protocols/http.tsx b/frontend/src/pages/xray/outbounds/protocols/http.tsx index 895f71f64..794e6aabb 100644 --- a/frontend/src/pages/xray/outbounds/protocols/http.tsx +++ b/frontend/src/pages/xray/outbounds/protocols/http.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { Form, Input } from 'antd'; +import { HeaderMapEditor } from '@/components/form'; + export default function HttpFields() { const { t } = useTranslation(); return ( @@ -11,6 +13,9 @@ export default function HttpFields() { + + + ); } diff --git a/frontend/src/schemas/forms/outbound-form.ts b/frontend/src/schemas/forms/outbound-form.ts index b26d123bd..85ea8fcfc 100644 --- a/frontend/src/schemas/forms/outbound-form.ts +++ b/frontend/src/schemas/forms/outbound-form.ts @@ -80,6 +80,7 @@ export const HttpOutboundFormSettingsSchema = z.object({ port: PortSchema.default(8080), user: z.string().default(''), pass: z.string().default(''), + headers: z.record(z.string(), z.string()).default({}), }); export type HttpOutboundFormSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/http.ts b/frontend/src/schemas/protocols/outbound/http.ts index 83aa143f6..0d6a86889 100644 --- a/frontend/src/schemas/protocols/outbound/http.ts +++ b/frontend/src/schemas/protocols/outbound/http.ts @@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer; export const HttpOutboundSettingsSchema = z.object({ servers: z.array(HttpOutboundServerSchema).min(1), + headers: z.record(z.string(), z.string()).optional(), }); export type HttpOutboundSettings = z.infer; diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index ca0169435..d4a9542e8 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -175,6 +175,29 @@ describe('outbound-form-adapter: round-trip', () => { }); }); + it('http preserves top-level settings.headers across wire → form → wire (#5519)', () => { + const headers = { 'X-T5-Auth': '683556433', Host: '153.3.236.22:443' }; + const form = rawOutboundToFormValues({ + protocol: 'http', + tag: 'h', + settings: { servers: [{ address: 'a', port: 443, users: [] }], headers }, + }); + expect(form.protocol).toBe('http'); + if (form.protocol === 'http') { + expect(form.settings.headers).toEqual(headers); + } + const back = formValuesToWirePayload(form); + expect(back.settings).toMatchObject({ headers }); + }); + + it('http omits headers when empty', () => { + const back = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'http', + settings: { servers: [{ address: 'a', port: 8080, users: [] }] }, + })); + expect(back.settings).not.toHaveProperty('headers'); + }); + it('wireguard csv-joins address and reserved on read, splits on write', () => { const wire = { protocol: 'wireguard',