Ver Fonte

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.
MHSanaei há 8 horas atrás
pai
commit
bd60e770f4

+ 31 - 2
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<string, string> {
+  const obj = asObject(raw);
+  const out: Record<string, string> = {};
+  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;

+ 5 - 0
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() {
       <Form.Item label={t('password')} name={['settings', 'pass']}>
         <Input />
       </Form.Item>
+      <Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
+        <HeaderMapEditor mode="v1" />
+      </Form.Item>
     </>
   );
 }

+ 1 - 0
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<typeof HttpOutboundFormSettingsSchema>;
 

+ 1 - 0
frontend/src/schemas/protocols/outbound/http.ts

@@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
 
 export const HttpOutboundSettingsSchema = z.object({
   servers: z.array(HttpOutboundServerSchema).min(1),
+  headers: z.record(z.string(), z.string()).optional(),
 });
 export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;

+ 23 - 0
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',