mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 13:07:14 +00:00
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.
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
|||||||
DnsRuleForm,
|
DnsRuleForm,
|
||||||
FreedomFinalRuleForm,
|
FreedomFinalRuleForm,
|
||||||
FreedomOutboundFormSettings,
|
FreedomOutboundFormSettings,
|
||||||
|
HttpOutboundFormSettings,
|
||||||
HysteriaOutboundFormSettings,
|
HysteriaOutboundFormSettings,
|
||||||
LoopbackOutboundFormSettings,
|
LoopbackOutboundFormSettings,
|
||||||
MuxForm,
|
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 {
|
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
|
||||||
const secretKey = asString(raw.secretKey);
|
const secretKey = asString(raw.secretKey);
|
||||||
const pubKey = secretKey.length > 0
|
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 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
|
||||||
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
|
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
|
||||||
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; 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 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
|
||||||
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
|
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
|
||||||
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(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) {
|
function wireguardToWire(s: WireguardOutboundFormSettings) {
|
||||||
return {
|
return {
|
||||||
mtu: s.mtu || undefined,
|
mtu: s.mtu || undefined,
|
||||||
@@ -629,7 +658,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
|
|||||||
case 'trojan': settings = trojanToWire(values.settings); break;
|
case 'trojan': settings = trojanToWire(values.settings); break;
|
||||||
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
|
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
|
||||||
case 'socks': settings = simpleAuthToWire(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 'wireguard': settings = wireguardToWire(values.settings); break;
|
||||||
case 'hysteria': settings = hysteriaToWire(values.settings); break;
|
case 'hysteria': settings = hysteriaToWire(values.settings); break;
|
||||||
case 'freedom': settings = freedomToWire(values.settings); break;
|
case 'freedom': settings = freedomToWire(values.settings); break;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Form, Input } from 'antd';
|
import { Form, Input } from 'antd';
|
||||||
|
|
||||||
|
import { HeaderMapEditor } from '@/components/form';
|
||||||
|
|
||||||
export default function HttpFields() {
|
export default function HttpFields() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -11,6 +13,9 @@ export default function HttpFields() {
|
|||||||
<Form.Item label={t('password')} name={['settings', 'pass']}>
|
<Form.Item label={t('password')} name={['settings', 'pass']}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
|
||||||
|
<HeaderMapEditor mode="v1" />
|
||||||
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const HttpOutboundFormSettingsSchema = z.object({
|
|||||||
port: PortSchema.default(8080),
|
port: PortSchema.default(8080),
|
||||||
user: z.string().default(''),
|
user: z.string().default(''),
|
||||||
pass: z.string().default(''),
|
pass: z.string().default(''),
|
||||||
|
headers: z.record(z.string(), z.string()).default({}),
|
||||||
});
|
});
|
||||||
export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
|
export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
|
|||||||
|
|
||||||
export const HttpOutboundSettingsSchema = z.object({
|
export const HttpOutboundSettingsSchema = z.object({
|
||||||
servers: z.array(HttpOutboundServerSchema).min(1),
|
servers: z.array(HttpOutboundServerSchema).min(1),
|
||||||
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
});
|
});
|
||||||
export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;
|
export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;
|
||||||
|
|||||||
@@ -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', () => {
|
it('wireguard csv-joins address and reserved on read, splits on write', () => {
|
||||||
const wire = {
|
const wire = {
|
||||||
protocol: 'wireguard',
|
protocol: 'wireguard',
|
||||||
|
|||||||
Reference in New Issue
Block a user