Files
3x-ui/frontend/src/lib/xray/outbound-form-adapter.ts
T
MHSanaei 62423cacd1 feat(xray): update xray-core to v26.9.30 and adapt panel
Bump xtls/xray-core to b26a91de4f (v26.9.30) and the three binary pins
(DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted
symbols; the sing and sing-shadowsocks indirect deps drop out with the
SS2022 rewrite.

XDNS finalmask (#6718) replaced its string lists with objects: domains
are {name, types, edns0, lenLimit, labelLimit} and client resolvers
{type, settings.addr}. The loader no longer parses the old lists, so a
single stored xdns mask keeps the whole core from starting. The new leaf
package internal/util/maskcompat converts them: "name[:type]" becomes a
domain and "name[:type]+udp://addr" a domain plus a udp resolver. A bare
name maps to TXT, the type legacy clients queried by default, and each
converted domain keeps the 1232-byte EDNS0 the old code always used
(without it the server caps answers at 512). It runs from:
- the XdnsFinalmaskObjectsFix seeder, over inbound streams, hosts, the
  xray template, the global sub-JSON mask and cached subscription
  outbounds;
- inbound save (normalizeStreamSettings) and GetXrayConfig, for rows
  that never went through the seeder;
- both link importers, since fm= from an older panel carries the lists.
The finalmask form edits the object shape (every key needs a registered
field, or the finalmask watch drops it on save) and lifts legacy masks
on open. The udp-mask golden fixture moves to the object shape, which
TestGoldenStreamFixturesBuildInXray now builds through the core. The
wire format changed as well, so pre-upgrade clients need the new core.

WireGuard outbound (#6771) dropped settings.domainStrategy and the
remoteDNS "local" mode. The endpoint lookup now follows
sockopt.domainStrategy and in-tunnel targets the outbound's
targetStrategy. The old key is silently ignored, which undid the
IPv4-first endpoint lookup the WARP outbound depends on (#5205), and
"local" now panics the core at startup because remoteDNS goes through
netip.MustParseAddr. The WireguardDomainStrategyFix seeder moves a stored
family preference to both keys (a value already set wins) and turns
"local" into targetStrategy; the outbound form lifts legacy rows the same
way and drops its select, the WARP modal writes the new placement, and
the inbound form loses a field the server never read.
ValidateOutboundConfig now refuses a non-IP remoteDNS entry, which
conf.Build() lets through, on template save and for outbound
subscriptions.

Noise finalmask items accept type "exp" (#6862), a tag expression. The
form offers it for noise items only: header-custom items go through the
core's PraseByteSlice, which refuses it.

TUN gained autoSystemDnsToGateway (Linux) and autoSystemWfpBlockLeak
(Windows). Both pass through the settings schema so a value set in JSON
survives the next form save.

MASQUE (inbound, outbound, transport) and the XDRIVE transport are new
protocols the panel does not offer yet; their new loader refusals only
cover configs the panel never generates. The FakeDNS IPv6 pool default,
the SS2022 rewrite (same gRPC account; emails are now deduped
case-insensitively, as the panel already does), the restored udphop
interval default and the rest change no panel-facing config.
2026-10-02 13:54:39 +02:00

995 lines
33 KiB
TypeScript

import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
import {
dropEmptyFinalMask,
normalizeStreamSettingsForWire,
} from '@/lib/xray/stream-wire-normalize';
import { Wireguard } from '@/utils';
import type { Sniffing, SniffingDest } from '@/schemas/primitives';
import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
import type {
AmneziaWGOutboundFormSettings,
BlackholeOutboundFormSettings,
DnsOutboundFormSettings,
DnsRuleForm,
FreedomFinalRuleForm,
FreedomOutboundFormSettings,
HttpOutboundFormSettings,
HysteriaOutboundFormSettings,
LoopbackOutboundFormSettings,
MuxForm,
OutboundFormSettings,
OutboundFormValues,
OutboundStreamFormValues,
ShadowsocksOutboundFormSettings,
TrojanOutboundFormSettings,
VlessOutboundFormSettings,
VmessOutboundFormSettings,
WireguardOutboundFormPeer,
WireguardOutboundFormSettings,
} from '@/schemas/forms/outbound-form';
type Raw = Record<string, unknown>;
function asObject(value: unknown): Raw {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
return fallback;
}
function asBool(value: unknown): boolean {
return value === true;
}
function asPort(value: unknown, fallback: number): number {
const n = asNumber(value, fallback);
if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
return n;
}
// xray-core matches targetStrategy/domainStrategy case-insensitively;
// normalize the wire value to the canonical spelling or '' (= AsIs).
function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
const s = asString(value);
if (!s) return '';
return (
OutboundDomainStrategySchema.options.find((v) => v.toLowerCase() === s.toLowerCase()) ?? ''
);
}
// Mirrors the order the loader migrates freedom's legacy strategy keys in:
// root targetStrategy, settings targetStrategy, settings domainStrategy, sockopt.
export function freedomDomainStrategyFromWire(outbound: {
targetStrategy?: unknown;
settings?: unknown;
streamSettings?: unknown;
}): OutboundDomainStrategy | '' {
const settings = asObject(outbound.settings);
const sockopt = asObject(asObject(outbound.streamSettings).sockopt);
const root = targetStrategyFromWire(outbound.targetStrategy);
const settingsKey = asString(settings.targetStrategy)
? settings.targetStrategy
: settings.domainStrategy;
const legacy = root && root !== 'AsIs' ? root : targetStrategyFromWire(settingsKey);
if (legacy && legacy !== 'AsIs') return legacy;
return targetStrategyFromWire(sockopt.domainStrategy);
}
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
const SNIFFING_DEFAULT: Sniffing = {
enabled: false,
destOverride: [...SNIFFING_DEST_VALUES],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
};
// Shared by VLESS reverse sniffing and the loopback outbound — both edit the
// same xray SniffingConfig. Unknown destOverride tokens are dropped so the
// value satisfies SniffingSchema's enum.
function sniffingFromWire(raw: unknown): Sniffing {
const r = asObject(raw);
const dest = asArray(r.destOverride)
.map((x) => asString(x))
.filter((x): x is SniffingDest => (SNIFFING_DEST_VALUES as readonly string[]).includes(x));
return {
enabled: asBool(r.enabled),
destOverride: dest.length > 0 ? dest : [...SNIFFING_DEST_VALUES],
metadataOnly: asBool(r.metadataOnly),
routeOnly: asBool(r.routeOnly),
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
};
}
function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
const vnext = asArray(raw.vnext);
const v = asObject(vnext[0]);
const u = asObject(asArray(v.users)[0]);
return {
address: asString(v.address),
port: asPort(v.port, 443),
id: asString(u.id),
security: ((): VmessOutboundFormSettings['security'] => {
const s = asString(u.security);
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto'];
return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
})(),
};
}
function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
let address = asString(raw.address);
let port = asPort(raw.port, 443);
let id = asString(raw.id);
let flow = asString(raw.flow);
let encryption = asString(raw.encryption, 'none');
const vnext = asArray(raw.vnext);
if (vnext.length > 0) {
const v = asObject(vnext[0]);
const u = asObject(asArray(v.users)[0]);
address = asString(v.address);
port = asPort(v.port, 443);
id = asString(u.id);
flow = asString(u.flow);
encryption = asString(u.encryption, 'none');
}
const reverse = asObject(raw.reverse);
const reverseTag = asString(reverse.tag);
const reverseSniffing = reverseTag ? sniffingFromWire(reverse.sniffing) : SNIFFING_DEFAULT;
const savedSeed = asArray(raw.testseed);
const testseed =
savedSeed.length === 4 && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
? (savedSeed as number[])
: [900, 500, 900, 256];
return {
address,
port,
id,
flow,
encryption: encryption || 'none',
reverseTag,
reverseSniffing,
testpre: asNumber(raw.testpre, 0),
testseed,
};
}
function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
const s = asObject(asArray(raw.servers)[0]);
return {
address: asString(s.address),
port: asPort(s.port, 443),
password: asString(s.password),
};
}
function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
const s = asObject(asArray(raw.servers)[0]);
return {
address: asString(s.address),
port: asPort(s.port, 443),
password: asString(s.password),
method: asString(
s.method,
'2022-blake3-aes-128-gcm',
) as ShadowsocksOutboundFormSettings['method'],
uot: asBool(s.uot),
UoTVersion: asNumber(s.UoTVersion, 1),
};
}
interface SimpleAuthFormSettings {
address: string;
port: number;
user: string;
pass: string;
}
function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
const s = asObject(asArray(raw.servers)[0]);
const u = asObject(asArray(s.users)[0]);
return {
address: asString(s.address),
port: asPort(s.port, defaultPort),
user: asString(u.user),
pass: asString(u.pass),
};
}
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 ? Wireguard.generateKeypair(secretKey).publicKey : '';
const addressArr = asArray(raw.address).map((x) =>
typeof x === 'number' ? String(x) : asString(x),
);
const reservedArr = asArray(raw.reserved).map((x) =>
typeof x === 'number' ? String(x) : asString(x),
);
const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
const pp = asObject(p);
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
return {
publicKey: asString(pp.publicKey),
psk: asString(pp.preSharedKey),
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
endpoint: asString(pp.endpoint),
keepAlive: asNumber(pp.keepAlive, 0),
};
});
return {
mtu: asNumber(raw.mtu, 1420),
secretKey,
pubKey,
address: addressArr.join(','),
reserved: reservedArr.join(','),
remoteDNS: isLegacyLocalRemoteDNS(raw.remoteDNS)
? ''
: asArray(raw.remoteDNS)
.map((x) => asString(x))
.join(','),
peers,
noKernelTun: asBool(raw.noKernelTun),
};
}
// remoteDNS ["local"] is a mode xray-core 26.9.30 removed; the core now panics parsing
// it as an address, so liftLegacyWireguardStrategy carries it over to targetStrategy.
function isLegacyLocalRemoteDNS(value: unknown): boolean {
const list = asArray(value);
return list.length === 1 && list[0] === 'local';
}
const isAsIs = (strategy: OutboundDomainStrategy | '') => strategy === '' || strategy === 'AsIs';
// xray-core 26.9.30 (#6771) ignores wireguard's settings.domainStrategy: sockopt.domainStrategy
// now picks the endpoint's family, targetStrategy the targets'. Mirrors the Go seeder.
function liftLegacyWireguardStrategy(
settings: Raw,
targetStrategy: OutboundDomainStrategy | '',
streamSettings: OutboundStreamFormValues | undefined,
): { targetStrategy: OutboundDomainStrategy | ''; streamSettings?: OutboundStreamFormValues } {
const legacy = targetStrategyFromWire(settings.domainStrategy);
const family = legacy.startsWith('ForceIP') && legacy !== 'ForceIP' ? legacy : '';
const lifted = family || (isLegacyLocalRemoteDNS(settings.remoteDNS) ? 'ForceIP' : '');
let stream = streamSettings;
const sockopt = asObject((stream as Raw | undefined)?.sockopt);
if (family && isAsIs(targetStrategyFromWire(sockopt.domainStrategy))) {
stream = {
...(stream ?? {}),
sockopt: { ...sockopt, domainStrategy: family },
} as OutboundStreamFormValues;
}
return {
targetStrategy: lifted && isAsIs(targetStrategy) ? lifted : targetStrategy,
streamSettings: stream,
};
}
function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
return {
address: asString(raw.address),
port: asPort(raw.port, 443),
version: 2,
};
}
function freedomFromWire(
raw: Raw,
domainStrategy: OutboundDomainStrategy | '',
): FreedomOutboundFormSettings {
const fragment = asObject(raw.fragment);
const noises = asArray(raw.noises).map((n) => {
const nn = asObject(n);
return {
type: asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type'],
packet: asString(nn.packet, '10-20'),
delay: asString(nn.delay, '10-16'),
applyTo: asString(
nn.applyTo,
'ip',
) as FreedomOutboundFormSettings['noises'][number]['applyTo'],
};
});
const finalRulesRaw = asArray(raw.finalRules);
const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
const rr = asObject(r);
const network = Array.isArray(rr.network)
? rr.network.map((x) => asString(x)).join(',')
: asString(rr.network);
return {
action: (asString(rr.action, 'block') === 'allow'
? 'allow'
: 'block') as FreedomFinalRuleForm['action'],
network,
port: asString(rr.port),
ip: asArray(rr.ip).map((x) => asString(x)),
blockDelay: asString(rr.blockDelay),
};
});
// Legacy ipsBlocked → finalRule(block) backfill
if (finalRules.length === 0) {
const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
if (ipsBlocked.length > 0) {
finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
}
}
// Wire fragment is either missing or a populated object. Mirror the
// legacy behavior: when the wire omits fragment, leave all four fields
// empty so the modal's "Fragment" Switch starts off. When present,
// surface whatever the wire holds verbatim.
const wireHasFragment =
raw.fragment != null && typeof raw.fragment === 'object' && Object.keys(fragment).length > 0;
return {
domainStrategy,
redirect: asString(raw.redirect),
userLevel: asNumber(raw.userLevel, 0),
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
const n = asNumber(raw.proxyProtocol, 0);
return n === 1 || n === 2 ? n : 0;
})(),
fragment: wireHasFragment
? {
packets: asString(fragment.packets, '1-3'),
length: asString(fragment.length),
interval: asString(fragment.interval),
maxSplit: asString(fragment.maxSplit),
}
: { packets: '', length: '', interval: '', maxSplit: '' },
noises,
finalRules,
};
}
function blackholeFromWire(raw: Raw): BlackholeOutboundFormSettings {
const response = asObject(raw.response);
const t = asString(response.type);
return {
type: t === 'none' || t === 'http' || t === 'custom' ? t : '',
customResponseData: asString(response.customResponseData),
};
}
function dnsRuleFromWire(raw: unknown): DnsRuleForm {
const r = asObject(raw);
const rawQType = r.qType ?? r.qtype;
const qType = Array.isArray(rawQType)
? rawQType.map((x) => String(x)).join(',')
: typeof rawQType === 'number'
? String(rawQType)
: asString(rawQType);
const domain = Array.isArray(r.domain)
? r.domain.map((x) => asString(x)).join(',')
: asString(r.domain);
const action = asString(r.action, 'direct');
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action) ? action : 'direct';
return {
action: validAction as DnsRuleForm['action'],
qType,
domain,
rCode: asNumber(r.rCode, 0),
};
}
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
const rules = asArray(raw.rules).map(dnsRuleFromWire);
return {
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
const s = asString(raw.rewriteNetwork ?? raw.network);
return s === 'udp' || s === 'tcp' ? s : '';
})(),
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
userLevel: asNumber(raw.userLevel, 0),
rules,
};
}
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
return {
inboundTag: asString(raw.inboundTag),
sniffing: sniffingFromWire(raw.sniffing),
};
}
function amneziawgPeerFromWire(p: unknown): AmneziaWGOutboundFormSettings['peers'][number] {
const pp = asObject(p);
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
return {
publicKey: asString(pp.publicKey),
presharedKey: asString(pp.presharedKey),
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
endpoint: asString(pp.endpoint),
keepAlive: asNumber(pp.keepAlive, 0),
};
}
// The form state IS the wire shape; hydrate only to apply defaults for keys
// an older template may omit.
function amneziawgFromWire(raw: Raw): AmneziaWGOutboundFormSettings {
return AmneziaWGOutboundSettingsSchema.parse({
mtu: asNumber(raw.mtu, 0),
secretKey: asString(raw.secretKey),
address: asArray(raw.address).map((x) => asString(x)),
listenPort: asNumber(raw.listenPort, 0),
dns: asString(raw.dns),
jc: asNumber(raw.jc, 0),
jmin: asNumber(raw.jmin, 40),
jmax: asNumber(raw.jmax, 100),
s1: asNumber(raw.s1, 15),
s2: asNumber(raw.s2, 80),
s3: asNumber(raw.s3, 12),
s4: asNumber(raw.s4, 12),
h1: asString(raw.h1),
h2: asString(raw.h2),
h3: asString(raw.h3),
h4: asString(raw.h4),
i1: asString(raw.i1),
i2: asString(raw.i2),
i3: asString(raw.i3),
i4: asString(raw.i4),
i5: asString(raw.i5),
headerProtectionKey: asString(raw.headerProtectionKey),
contentPaddingAddition: asString(raw.contentPaddingAddition),
rekeyAfterTime: asString(raw.rekeyAfterTime),
rekeyTimeout: asString(raw.rekeyTimeout),
rejectAfterTime: asString(raw.rejectAfterTime),
keepaliveTimeout: asString(raw.keepaliveTimeout),
maxHandshakeAttempts: asString(raw.maxHandshakeAttempts),
randomTrailers: raw.randomTrailers === undefined ? false : asBool(raw.randomTrailers),
disableCookies: raw.disableCookies === undefined ? true : asBool(raw.disableCookies),
peers: asArray(raw.peers).map(amneziawgPeerFromWire),
});
}
function amneziawgToWire(s: AmneziaWGOutboundFormSettings): Raw {
const out: Raw = {
mtu: s.mtu || undefined,
secretKey: s.secretKey,
address: s.address,
jc: s.jc,
jmin: s.jmin,
jmax: s.jmax,
s1: s.s1,
s2: s.s2,
s3: s.s3,
s4: s.s4,
h1: s.h1,
h2: s.h2,
h3: s.h3,
h4: s.h4,
randomTrailers: s.randomTrailers,
disableCookies: s.disableCookies,
peers: s.peers.map((p) => ({
publicKey: p.publicKey,
presharedKey: p.presharedKey.length > 0 ? p.presharedKey : undefined,
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
endpoint: p.endpoint,
keepAlive: p.keepAlive || undefined,
})),
};
if (s.listenPort > 0) out.listenPort = s.listenPort;
if (s.dns && s.dns.length > 0) out.dns = s.dns;
const optionalStrings = [
'i1',
'i2',
'i3',
'i4',
'i5',
'headerProtectionKey',
'contentPaddingAddition',
'rekeyAfterTime',
'rekeyTimeout',
'rejectAfterTime',
'keepaliveTimeout',
'maxHandshakeAttempts',
] as const;
for (const k of optionalStrings) {
if (s[k].length > 0) out[k] = s[k];
}
return out;
}
function muxFromWire(raw: unknown): MuxForm {
const m = asObject(raw);
return {
enabled: asBool(m.enabled),
concurrency: asNumber(m.concurrency, 8),
xudpConcurrency: asNumber(m.xudpConcurrency, 16),
xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
const s = asString(m.xudpProxyUDP443, 'reject');
return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
})(),
};
}
export interface RawOutboundRow {
tag?: string;
protocol?: string;
sendThrough?: string;
targetStrategy?: string;
settings?: unknown;
streamSettings?: unknown;
mux?: unknown;
}
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
const next = { ...stream };
if (typeof next.method === 'string' && next.method !== '') {
next.network = next.method;
}
delete next.method;
const xh = next.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const xhttp = { ...(xh as Raw) };
const xmux = xhttp.xmux;
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
xhttp.enableXmux = true;
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Raw) };
}
next.xhttpSettings = xhttp;
}
return next as unknown as OutboundStreamFormValues;
}
export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
// The core lowercases a protocol id before it looks the handler up, so a
// template pasted as "Freedom" must not fall through to the vless default.
const protocol = asString(raw.protocol, 'vless').toLowerCase();
const settings = asObject(raw.settings);
const tag = asString(raw.tag);
const sendThrough = asString(raw.sendThrough);
const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
const freedomStrategy = freedomDomainStrategyFromWire(raw);
const mux = muxFromWire(raw.mux);
const hasStream =
raw.streamSettings &&
typeof raw.streamSettings === 'object' &&
Object.keys(raw.streamSettings as Raw).length > 0;
const streamSettings = hasStream ? hydrateStreamForm(raw.streamSettings as Raw) : undefined;
let typed: OutboundFormSettings;
switch (protocol) {
case 'vmess':
typed = { protocol: 'vmess', settings: vmessFromWire(settings) };
break;
case 'vless':
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
break;
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: httpFromWire(settings) };
break;
case 'wireguard':
typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
break;
case 'amneziawg':
typed = { protocol: 'amneziawg', settings: amneziawgFromWire(settings) };
break;
case 'hysteria':
typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
break;
case 'freedom':
typed = {
protocol: 'freedom',
settings: freedomFromWire(settings, freedomStrategy),
};
break;
case 'blackhole':
typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) };
break;
case 'dns':
typed = { protocol: 'dns', settings: dnsFromWire(settings) };
break;
case 'loopback':
typed = { protocol: 'loopback', settings: loopbackFromWire(settings) };
break;
default:
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
}
const placed =
protocol === 'wireguard'
? liftLegacyWireguardStrategy(settings, targetStrategy, streamSettings)
: { targetStrategy, streamSettings };
return {
...typed,
tag,
sendThrough,
// The freedom card owns the strategy for freedom, so the shared root field
// stays empty and cannot disagree with what the card is showing.
targetStrategy: protocol === 'freedom' ? '' : placed.targetStrategy,
mux,
streamSettings: placed.streamSettings,
};
}
// --- Form values -> wire payload --------------------------------------
function vmessToWire(s: VmessOutboundFormSettings) {
return {
vnext: [
{
address: s.address,
port: s.port,
users: [{ id: s.id, security: s.security }],
},
],
};
}
function sniffingToWire(s: Sniffing) {
return {
enabled: s.enabled,
destOverride: s.destOverride,
metadataOnly: s.metadataOnly,
routeOnly: s.routeOnly,
ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
};
}
function vlessToWire(s: VlessOutboundFormSettings) {
const result: Raw = {
address: s.address,
port: s.port,
id: s.id,
flow: s.flow,
encryption: s.encryption || 'none',
};
if (s.reverseTag) {
const sn = sniffingToWire(s.reverseSniffing);
const defaultSn = sniffingToWire(SNIFFING_DEFAULT);
result.reverse = {
tag: s.reverseTag,
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
};
}
if (s.flow === 'xtls-rprx-vision') {
if (s.testpre > 0) result.testpre = s.testpre;
if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
result.testseed = s.testseed;
}
}
return result;
}
function trojanToWire(s: TrojanOutboundFormSettings) {
return { servers: [{ address: s.address, port: s.port, password: s.password }] };
}
function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
return {
servers: [
{
address: s.address,
port: s.port,
password: s.password,
method: s.method,
uot: s.uot,
UoTVersion: s.UoTVersion,
},
],
};
}
function simpleAuthToWire(s: SimpleAuthFormSettings) {
return {
servers: [
{
address: s.address,
port: s.port,
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
},
],
};
}
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,
secretKey: s.secretKey,
address: s.address
? s.address
.split(',')
.map((x) => x.trim())
.filter(Boolean)
: [],
reserved: s.reserved
? s.reserved
.split(',')
.map((x) => Number(x.trim()))
.filter((n) => Number.isFinite(n))
: undefined,
remoteDNS: s.remoteDNS
? s.remoteDNS
.split(',')
.map((x) => x.trim())
.filter(Boolean)
: undefined,
peers: s.peers.map((p) => ({
publicKey: p.publicKey,
preSharedKey: p.psk.length > 0 ? p.psk : undefined,
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
endpoint: p.endpoint,
keepAlive: p.keepAlive || undefined,
})),
noKernelTun: s.noKernelTun,
};
}
function hysteriaToWire(s: HysteriaOutboundFormSettings) {
return { address: s.address, port: s.port, version: s.version };
}
function freedomToWire(s: FreedomOutboundFormSettings) {
// Legacy semantics: emit fragment only when the user actually populated
// at least one of the four sub-fields. Defaults like packets='1-3' alone
// are not enough — the modal's Fragment Switch sets all four together.
// getFieldsValue(true) may omit `fragment` when the switch is off, so the
// fallback keeps Object.entries from throwing on undefined (issue #4686).
const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
// domainStrategy is absent here on purpose: formValuesToWirePayload hoists it
// into streamSettings.sockopt, the only placement freedom resolves with.
return {
redirect: s.redirect || undefined,
userLevel: s.userLevel || undefined,
proxyProtocol: s.proxyProtocol || undefined,
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
finalRules:
s.finalRules && s.finalRules.length > 0
? s.finalRules.map((r) => ({
action: r.action,
network: r.network || undefined,
port: r.port || undefined,
ip: r.ip.length > 0 ? r.ip : undefined,
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
}))
: undefined,
};
}
function blackholeToWire(s: BlackholeOutboundFormSettings) {
if (!s.type) return { response: undefined };
if (s.type === 'custom') {
return { response: { type: s.type, customResponseData: s.customResponseData } };
}
return { response: { type: s.type } };
}
function dnsRuleToWire(r: DnsRuleForm) {
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action) ? r.action : 'direct';
const result: Raw = { action };
const qType = r.qType.trim();
if (qType) {
// The core reads a numeric 0 as no qType at all, which matches every query.
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
}
const domains = r.domain
.split(',')
.map((d) => d.trim())
.filter(Boolean);
if (domains.length > 0) result.domain = domains;
if (r.rCode > 0) result.rCode = r.rCode;
return result;
}
function dnsToWire(s: DnsOutboundFormSettings) {
const result: Raw = {};
if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
if (s.rewritePort) result.rewritePort = s.rewritePort;
if (s.userLevel) result.userLevel = s.userLevel;
if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
return result;
}
function loopbackToWire(s: LoopbackOutboundFormSettings) {
const result: Raw = { inboundTag: s.inboundTag || undefined };
// Sniffing rides only when enabled — a disabled block is a no-op for
// xray's BuildSniffingRequest, so omitting it keeps the wire minimal.
if (s.sniffing.enabled) {
result.sniffing = sniffingToWire(s.sniffing);
}
return result;
}
// canEnableMux mirrors the legacy Outbound.canEnableMux().
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
function dropEmptyStrings(obj: Raw): Raw {
const out: Raw = {};
for (const [k, v] of Object.entries(obj)) {
if (v === '') continue;
out[k] = v;
}
return out;
}
function stripUiOnlyStreamFields(stream: unknown): Raw {
const next = { ...(stream as Raw) };
const xh = next.xhttpSettings;
if (xh && typeof xh === 'object') {
const cleaned = { ...(xh as Raw) };
const xmuxEnabled = cleaned.enableXmux === true;
delete cleaned.enableXmux;
if (!xmuxEnabled) delete cleaned.xmux;
next.xhttpSettings = dropEmptyStrings(cleaned);
}
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
}
function muxAllowed(values: OutboundFormValues): boolean {
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
const flow =
values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : '';
if (flow) return false;
const network =
values.streamSettings && 'network' in values.streamSettings
? values.streamSettings.network
: undefined;
if (network === 'xhttp') return false;
return true;
}
export type WireOutboundPayload = Raw;
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
let settings: Raw;
switch (values.protocol) {
case 'vmess':
settings = vmessToWire(values.settings);
break;
case 'vless':
settings = vlessToWire(values.settings);
break;
case 'trojan':
settings = trojanToWire(values.settings);
break;
case 'shadowsocks':
settings = shadowsocksToWire(values.settings);
break;
case 'socks':
settings = simpleAuthToWire(values.settings);
break;
case 'http':
settings = httpToWire(values.settings);
break;
case 'wireguard':
settings = wireguardToWire(values.settings);
break;
case 'amneziawg':
settings = amneziawgToWire(values.settings);
break;
case 'hysteria':
settings = hysteriaToWire(values.settings);
break;
case 'freedom':
settings = freedomToWire(values.settings);
break;
case 'blackhole':
settings = blackholeToWire(values.settings);
break;
case 'dns':
settings = dnsToWire(values.settings);
break;
case 'loopback':
settings = loopbackToWire(values.settings);
break;
}
const result: Raw = {
protocol: values.protocol,
settings,
};
if (values.tag) result.tag = values.tag;
if (values.targetStrategy && values.protocol !== 'freedom') {
result.targetStrategy = values.targetStrategy;
}
// Non-stream protocols emit only `sockopt`; wireguard also keeps `finalmask`, which the
// core dials its peer through (the one non-stream protocol the mask editor renders for).
if (values.streamSettings) {
if (STREAM_PROTOCOLS.has(values.protocol)) {
result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
} else {
const { sockopt, finalmask } = values.streamSettings as {
sockopt?: unknown;
finalmask?: unknown;
};
const stream: Raw = {};
if (sockopt) stream.sockopt = sockopt;
if (values.protocol === 'wireguard' && finalmask && typeof finalmask === 'object') {
stream.finalmask = { ...(finalmask as Raw) };
dropEmptyFinalMask(stream);
}
if (Object.keys(stream).length > 0) result.streamSettings = stream;
}
}
// Freedom only honours sockopt.domainStrategy; the root and settings keys are
// legacy aliases the loader warns about on every start (infra/conf/xray.go).
if (values.protocol === 'freedom') {
const stream = (result.streamSettings ?? {}) as Raw;
const sockopt = asObject(stream.sockopt);
const strategy = values.settings.domainStrategy || values.targetStrategy;
if (strategy && strategy !== 'AsIs') sockopt.domainStrategy = strategy;
else delete sockopt.domainStrategy;
if (Object.keys(sockopt).length > 0) stream.sockopt = sockopt;
else delete stream.sockopt;
if (Object.keys(stream).length > 0) result.streamSettings = stream;
else delete result.streamSettings;
}
if (values.sendThrough) result.sendThrough = values.sendThrough;
// mux may be absent when the modal didn't render the Mux switch (non-
// stream protocols or when isMuxAllowed gated it out). validateFields()
// only returns registered fields, so values.mux can be undefined.
if (values.mux?.enabled && muxAllowed(values)) {
result.mux = values.mux;
}
return result;
}