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; 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 { 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 ? 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 = 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; }