From b554bb6b757bc6ef79b06deff2f59321f755120b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 11:58:36 +0200 Subject: [PATCH] feat(frontend): outbound form schema + wire adapter foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay the groundwork for OutboundFormModal's Pattern A rewrite: - schemas/forms/outbound-form.ts: discriminated-union form values across all 12 outbound protocols, with flat per-protocol settings shapes that match the legacy class fields (vmess vnext / trojan-ss-socks-http servers / wireguard csv address-reserved all flattened). - lib/xray/outbound-form-adapter.ts: rawOutboundToFormValues converts wire-shape outbound JSON to typed form values; formValuesToWirePayload re-nests on submit. Replaces the Outbound.fromJson/toJson dependency the modal currently has on the legacy class hierarchy. - test/outbound-form-adapter.test.ts: 15 round-trip cases covering each protocol's wire quirks (vmess vnext flatten, vless reverse-wrap, wireguard csv↔array, blackhole response wrap, DNS rule normalization, mux gating). --- .../src/lib/xray/outbound-form-adapter.ts | 602 ++++++++++++++++++ frontend/src/schemas/forms/outbound-form.ts | 265 ++++++++ .../src/test/outbound-form-adapter.test.ts | 302 +++++++++ 3 files changed, 1169 insertions(+) create mode 100644 frontend/src/lib/xray/outbound-form-adapter.ts create mode 100644 frontend/src/schemas/forms/outbound-form.ts create mode 100644 frontend/src/test/outbound-form-adapter.test.ts diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts new file mode 100644 index 00000000..f6aafdf3 --- /dev/null +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -0,0 +1,602 @@ +import { Wireguard } from '@/utils'; + +import type { + DnsOutboundFormSettings, + DnsRuleForm, + FreedomFinalRuleForm, + FreedomOutboundFormSettings, + HysteriaOutboundFormSettings, + LoopbackOutboundFormSettings, + MuxForm, + OutboundFormSettings, + OutboundFormValues, + OutboundStreamFormValues, + ReverseSniffingForm, + ShadowsocksOutboundFormSettings, + TrojanOutboundFormSettings, + VlessOutboundFormSettings, + VmessOutboundFormSettings, + WireguardOutboundFormPeer, + WireguardOutboundFormSettings, +} from '@/schemas/forms/outbound-form'; + +// Adapter between the wire-shape outbound JSON the panel stores in +// templateSettings.outbounds[] and the typed OutboundFormValues the modal +// holds in Form.useForm. No dependency on the legacy Outbound class +// hierarchy — the modal hands a wire-shape object in, takes typed values +// out, and on submit calls formValuesToWirePayload() to get a plain JS +// object ready to pass to onConfirm(). + +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; +} + +const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = { + enabled: false, + destOverride: ['http', 'tls', 'quic', 'fakedns'], + metadataOnly: false, + routeOnly: false, + ipsExcluded: [], + domainsExcluded: [], +}; + +function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm { + const r = asObject(raw); + const dest = asArray(r.destOverride).map((x) => asString(x)); + return { + enabled: asBool(r.enabled), + destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'], + 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', 'none', 'zero']; + 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 + ? reverseSniffingFromWire(reverse.sniffing) + : REVERSE_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[]) + : []; + return { + address, + port, + id, + flow, + encryption: (encryption === 'none' ? 'none' : 'none') as '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 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(','), + workers: asNumber(raw.workers, 2), + domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => { + const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4']; + const s = asString(raw.domainStrategy); + return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy']; + })(), + reserved: reservedArr.join(','), + peers, + noKernelTun: asBool(raw.noKernelTun), + }; +} + +function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings { + return { + address: asString(raw.address), + port: asPort(raw.port, 443), + version: 2, + }; +} + +function freedomFromWire(raw: Raw): 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: ((): FreedomOutboundFormSettings['domainStrategy'] => { + const allowed = [ + 'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6', + 'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4', + ]; + const s = asString(raw.domainStrategy); + return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy']; + })(), + redirect: asString(raw.redirect), + 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) { + const response = asObject(raw.response); + const t = asString(response.type); + return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' }; +} + +function dnsRuleFromWire(raw: unknown): DnsRuleForm { + const r = asObject(raw); + const qtype = Array.isArray(r.qtype) + ? r.qtype.map((x) => String(x)).join(',') + : typeof r.qtype === 'number' + ? String(r.qtype) + : asString(r.qtype); + 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', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action) + ? action + : 'direct'; + return { action: validAction as DnsRuleForm['action'], qtype, domain }; +} + +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) }; +} + +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; + settings?: unknown; + streamSettings?: unknown; + mux?: unknown; +} + +// Convert wire-shape outbound (the object stored in +// templateSettings.outbounds[]) into typed form values. Stream + mux are +// minimal placeholders for now — the modal will fold the real stream sub- +// form in when those sections come online. +export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues { + const protocol = asString(raw.protocol, 'vless'); + const settings = asObject(raw.settings); + const tag = asString(raw.tag); + const sendThrough = asString(raw.sendThrough); + const mux = muxFromWire(raw.mux); + const streamSettings = asObject(raw.streamSettings) as unknown as OutboundStreamFormValues | 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: simpleAuthFromWire(settings, 8080) }; 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; + 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) }; + } + + return { + ...typed, + tag, + sendThrough, + mux, + 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 reverseSniffingToWire(s: ReverseSniffingForm) { + 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 = reverseSniffingToWire(s.reverseSniffing); + const defaultSn = reverseSniffingToWire(REVERSE_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 wireguardToWire(s: WireguardOutboundFormSettings) { + return { + mtu: s.mtu || undefined, + secretKey: s.secretKey, + address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [], + workers: s.workers || undefined, + domainStrategy: s.domainStrategy || undefined, + reserved: s.reserved + ? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n)) + : 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. + const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null); + const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit; + return { + domainStrategy: s.domainStrategy || undefined, + redirect: s.redirect || undefined, + fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined, + noises: s.noises.length > 0 ? s.noises : undefined, + 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: { type: '' | 'none' | 'http' }) { + return { response: s.type ? { type: s.type } : undefined }; +} + +function dnsRuleToWire(r: DnsRuleForm) { + const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action) + ? r.action + : 'direct'; + const result: Raw = { action }; + const qtype = r.qtype.trim(); + if (qtype) { + result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype; + } + const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean); + if (domains.length > 0) result.domain = domains; + 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) { + return { inboundTag: s.inboundTag || undefined }; +} + +// 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 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 = simpleAuthToWire(values.settings); break; + case 'wireguard': settings = wireguardToWire(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; + + // streamSettings emission gates on canEnableStream — non-stream protocols + // still emit just `sockopt` if that key is present (legacy behavior). + if (values.streamSettings) { + if (STREAM_PROTOCOLS.has(values.protocol)) { + result.streamSettings = values.streamSettings; + } else { + const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt; + if (sockopt) result.streamSettings = { sockopt }; + } + } + + if (values.sendThrough) result.sendThrough = values.sendThrough; + if (values.mux.enabled && muxAllowed(values)) { + result.mux = values.mux; + } + return result; +} diff --git a/frontend/src/schemas/forms/outbound-form.ts b/frontend/src/schemas/forms/outbound-form.ts new file mode 100644 index 00000000..c4ceb074 --- /dev/null +++ b/frontend/src/schemas/forms/outbound-form.ts @@ -0,0 +1,265 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; +import { VmessSecuritySchema } from '@/schemas/protocols/inbound/vmess'; +import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks'; +import { SecuritySettingsSchema } from '@/schemas/protocols/security'; +import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream'; +import { + BlackholeResponseTypeSchema, + DNSRuleActionSchema, + FreedomFinalRuleActionSchema, + FreedomFragmentSchema, + FreedomNoiseSchema, + OutboundDomainStrategySchema, + WireguardDomainStrategySchema, +} from '@/schemas/protocols/outbound'; + +// OutboundFormValues = the shape Form.useForm() carries inside +// OutboundFormModal. Differences from schemas/api wire schemas: +// +// - vmess vnext / trojan-ss-socks-http servers are FLATTENED into +// {address, port, ...auth} at settings root. The adapter handles +// nesting on submit. +// - wireguard `address` (string[] wire) and `reserved` (number[] wire) +// are comma-joined STRINGS in the form. The adapter splits + coerces. +// - wireguard `pubKey` is a UI-only field derived from `secretKey`. Not +// emitted on the wire — the adapter strips it. +// - VLESS `reverseTag` and `reverseSniffing` are flat at settings root; +// the adapter wraps them as { reverse: { tag, sniffing } } on the wire. +// - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it +// as { response: { type } } on the wire (omitted when empty). +// - DNS rules carry `qtype` and `domain` as comma-joined strings (matches +// the legacy DNSRule UI). The adapter normalizes them on submit. +// +// All flat-form settings types are documented inline so the adapter has a +// single source of truth for the shape it converts between. + +// VMess outbound: connect target (address+port) + first user (id+security). +// Wire: { vnext: [{ address, port, users: [{ id, security }] }] }. +export const VmessOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + id: z.string().default(''), + security: VmessSecuritySchema.default('auto'), +}); +export type VmessOutboundFormSettings = z.infer; + +// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults +// match legacy ReverseSniffing constructor. +export const ReverseSniffingFormSchema = z.object({ + enabled: z.boolean().default(false), + destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']), + metadataOnly: z.boolean().default(false), + routeOnly: z.boolean().default(false), + ipsExcluded: z.array(z.string()).default([]), + domainsExcluded: z.array(z.string()).default([]), +}); +export type ReverseSniffingForm = z.infer; + +// VLESS outbound: flat connect target + auth + Vision-specific knobs + +// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed. +export const VlessOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + id: z.string().default(''), + flow: z.string().default(''), + encryption: z.literal('none').default('none'), + reverseTag: z.string().default(''), + reverseSniffing: ReverseSniffingFormSchema.default({ + enabled: false, + destOverride: ['http', 'tls', 'quic', 'fakedns'], + metadataOnly: false, + routeOnly: false, + ipsExcluded: [], + domainsExcluded: [], + }), + testpre: z.number().int().min(0).default(0), + testseed: z.array(z.number().int().positive()).default([]), +}); +export type VlessOutboundFormSettings = z.infer; + +export const TrojanOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + password: z.string().default(''), +}); +export type TrojanOutboundFormSettings = z.infer; + +export const ShadowsocksOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + password: z.string().default(''), + method: SSMethodSchema.default('2022-blake3-aes-128-gcm'), + uot: z.boolean().default(false), + UoTVersion: z.number().int().min(1).max(2).default(1), +}); +export type ShadowsocksOutboundFormSettings = z.infer; + +// SOCKS / HTTP: panel only supports a single server, with optionally one +// user (the adapter emits users: [] when user is empty). +export const SocksOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(1080), + user: z.string().default(''), + pass: z.string().default(''), +}); +export type SocksOutboundFormSettings = z.infer; + +export const HttpOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(8080), + user: z.string().default(''), + pass: z.string().default(''), +}); +export type HttpOutboundFormSettings = z.infer; + +// Wireguard peer mirrors the legacy Outbound.WireguardSettings.Peer class. +// `psk` (form) <-> `preSharedKey` (wire) — adapter renames. +export const WireguardOutboundFormPeerSchema = z.object({ + publicKey: z.string().default(''), + psk: z.string().default(''), + allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']), + endpoint: z.string().default(''), + keepAlive: z.number().int().min(0).default(0), +}); +export type WireguardOutboundFormPeer = z.infer; + +// Wireguard: `address` and `reserved` are comma-joined strings in the form +// (the legacy UI binds them to a single Input). pubKey is UI-only — the +// modal derives it from secretKey via Wireguard.generateKeypair() and +// displays it disabled; the adapter strips it. +export const WireguardOutboundFormSettingsSchema = z.object({ + mtu: z.number().int().min(0).default(1420), + secretKey: z.string().default(''), + pubKey: z.string().default(''), + address: z.string().default(''), + workers: z.number().int().min(0).default(2), + domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''), + reserved: z.string().default(''), + peers: z.array(WireguardOutboundFormPeerSchema).default([]), + noKernelTun: z.boolean().default(false), +}); +export type WireguardOutboundFormSettings = z.infer; + +// Hysteria outbound carries the connect target only; transport-layer knobs +// (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria. +export const HysteriaOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + version: z.literal(2).default(2), +}); +export type HysteriaOutboundFormSettings = z.infer; + +// FinalRule (freedom): network/port are strings; ip is string[]; blockDelay +// is only meaningful when action === 'block'. The adapter omits empty +// fields from the wire payload. +export const FreedomFinalRuleFormSchema = z.object({ + action: FreedomFinalRuleActionSchema.default('block'), + network: z.string().default(''), + port: z.string().default(''), + ip: z.array(z.string()).default([]), + blockDelay: z.string().default(''), +}); +export type FreedomFinalRuleForm = z.infer; + +export const FreedomOutboundFormSettingsSchema = z.object({ + domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''), + redirect: z.string().default(''), + fragment: FreedomFragmentSchema.default({ + packets: '1-3', + length: '', + interval: '', + maxSplit: '', + }), + noises: z.array(FreedomNoiseSchema).default([]), + finalRules: z.array(FreedomFinalRuleFormSchema).default([]), +}); +export type FreedomOutboundFormSettings = z.infer; + +// Blackhole: legacy form keeps `type` as a flat string ('' | 'none' | 'http'); +// adapter wraps as { response: { type } } on the wire and omits when empty. +export const BlackholeOutboundFormSettingsSchema = z.object({ + type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''), +}); +export type BlackholeOutboundFormSettings = z.infer; + +// DNS rules: form holds qtype + domain as joined strings (the legacy UI +// binds to ). Adapter parses them on submit per the DNSRule class. +export const DnsRuleFormSchema = z.object({ + action: DNSRuleActionSchema.default('direct'), + qtype: z.string().default(''), + domain: z.string().default(''), +}); +export type DnsRuleForm = z.infer; + +export const DnsOutboundFormSettingsSchema = z.object({ + rewriteNetwork: z.union([z.enum(['udp', 'tcp']), z.literal('')]).default(''), + rewriteAddress: z.string().default(''), + rewritePort: z.number().int().min(0).max(65535).default(53), + userLevel: z.number().int().min(0).default(0), + rules: z.array(DnsRuleFormSchema).default([]), +}); +export type DnsOutboundFormSettings = z.infer; + +export const LoopbackOutboundFormSettingsSchema = z.object({ + inboundTag: z.string().default(''), +}); +export type LoopbackOutboundFormSettings = z.infer; + +// Discriminated union on `protocol`. Same tagged-wrapper pattern as the +// inbound side: each branch is { protocol: literal, settings: }. +export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [ + z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }), +]); +export type OutboundFormSettings = z.infer; + +// Mux ride: only emitted when enabled. The adapter respects canEnableMux +// (gated by protocol + flow + network). +export const MuxFormSchema = z.object({ + enabled: z.boolean().default(false), + concurrency: z.number().int().default(8), + xudpConcurrency: z.number().int().default(16), + xudpProxyUDP443: z.enum(['reject', 'allow', 'skip']).default('reject'), +}); +export type MuxForm = z.infer; + +// Stream form mirrors the inbound side: NetworkSettings DU + SecuritySettings +// DU + extras (sockopt). Hysteria gets a side-channel branch in the modal +// (legacy ob.stream.hysteria) — keeping the DU strict for now and routing +// hysteria transport knobs through the Advanced JSON tab if needed. +export const OutboundStreamFormSchema = NetworkSettingsSchema + .and(SecuritySettingsSchema) + .and(StreamExtrasSchema); +export type OutboundStreamFormValues = z.infer; + +// Top-level form base: identity (tag, sendThrough), then the per-protocol +// settings DU, then the stream sub-form, then mux. +export const OutboundFormBaseSchema = z.object({ + tag: z.string().default(''), + sendThrough: z.string().default(''), + streamSettings: OutboundStreamFormSchema.optional(), + mux: MuxFormSchema.default({ + enabled: false, + concurrency: 8, + xudpConcurrency: 16, + xudpProxyUDP443: 'reject', + }), +}); +export type OutboundFormBase = z.infer; + +// Full form values = base + protocol-discriminated settings. Consumers +// narrow on `.protocol` to access the matching settings branch. +export const OutboundFormSchema = OutboundFormBaseSchema.and(OutboundFormSettingsSchema); +export type OutboundFormValues = z.infer; diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts new file mode 100644 index 00000000..3fdf9186 --- /dev/null +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest'; + +import { + formValuesToWirePayload, + rawOutboundToFormValues, +} from '@/lib/xray/outbound-form-adapter'; + +// Round-trip parity: wire → form → wire should preserve the legacy +// Outbound.fromJson(...).toJson() output shape for each protocol's quirks. +// Spot-checking the cases the modal exercised in v0.x — vmess vnext flatten, +// vless reverse-wrap, wireguard address csv ↔ array, freedom finalRules +// emission, blackhole type wrap, dns rule normalization, mux gating. + +describe('outbound-form-adapter: round-trip', () => { + it('vmess flattens vnext to address/port/id/security and re-nests', () => { + const wire = { + protocol: 'vmess', + tag: 'outbound-vmess', + settings: { + vnext: [{ + address: '1.2.3.4', + port: 443, + users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }], + }], + }, + }; + const form = rawOutboundToFormValues(wire); + expect(form.protocol).toBe('vmess'); + if (form.protocol === 'vmess') { + expect(form.settings.address).toBe('1.2.3.4'); + expect(form.settings.port).toBe(443); + expect(form.settings.id).toBe('11111111-2222-4333-8444-555555555555'); + expect(form.settings.security).toBe('auto'); + } + const back = formValuesToWirePayload(form); + expect(back).toMatchObject({ + protocol: 'vmess', + tag: 'outbound-vmess', + settings: { + vnext: [{ + address: '1.2.3.4', + port: 443, + users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }], + }], + }, + }); + }); + + it('vless preserves flat shape and emits reverse only when reverseTag is set', () => { + const wire = { + protocol: 'vless', + tag: 'out-vless', + settings: { + address: 'srv.example', + port: 8443, + id: '11111111-2222-4333-8444-555555555555', + flow: 'xtls-rprx-vision', + encryption: 'none', + }, + }; + const form = rawOutboundToFormValues(wire); + expect(form.protocol).toBe('vless'); + if (form.protocol === 'vless') { + expect(form.settings.reverseTag).toBe(''); + } + const back = formValuesToWirePayload(form); + expect(back.settings).not.toHaveProperty('reverse'); + expect(back.settings).toMatchObject({ + address: 'srv.example', + port: 8443, + id: '11111111-2222-4333-8444-555555555555', + flow: 'xtls-rprx-vision', + encryption: 'none', + }); + }); + + it('vless emits reverse + sniffing when reverseTag is set', () => { + const wire = { + protocol: 'vless', + settings: { + address: 'srv', + port: 8443, + id: '11111111-2222-4333-8444-555555555555', + flow: '', + encryption: 'none', + reverse: { tag: 'rev-1', sniffing: { enabled: true, destOverride: ['tls'] } }, + }, + }; + const form = rawOutboundToFormValues(wire); + if (form.protocol === 'vless') { + expect(form.settings.reverseTag).toBe('rev-1'); + expect(form.settings.reverseSniffing.enabled).toBe(true); + expect(form.settings.reverseSniffing.destOverride).toEqual(['tls']); + } + const back = formValuesToWirePayload(form); + const settings = back.settings as Record; + expect(settings.reverse).toMatchObject({ tag: 'rev-1' }); + }); + + it('vless does not emit testpre/testseed unless flow is vision', () => { + const wire = { + protocol: 'vless', + settings: { + address: 'srv', port: 443, id: '11111111-2222-4333-8444-555555555555', + flow: '', encryption: 'none', testpre: 5, testseed: [1, 2, 3, 4], + }, + }; + const back = formValuesToWirePayload(rawOutboundToFormValues(wire)); + expect(back.settings).not.toHaveProperty('testpre'); + expect(back.settings).not.toHaveProperty('testseed'); + }); + + it('trojan flattens servers[0] and re-nests', () => { + const wire = { + protocol: 'trojan', + settings: { servers: [{ address: 's', port: 443, password: 'pw' }] }, + }; + const form = rawOutboundToFormValues(wire); + if (form.protocol === 'trojan') { + expect(form.settings).toEqual({ address: 's', port: 443, password: 'pw' }); + } + expect(formValuesToWirePayload(form).settings).toEqual({ + servers: [{ address: 's', port: 443, password: 'pw' }], + }); + }); + + it('shadowsocks preserves uot + UoTVersion', () => { + const wire = { + protocol: 'shadowsocks', + settings: { + servers: [{ + address: 's', port: 443, password: 'pw', + method: '2022-blake3-aes-128-gcm', uot: true, UoTVersion: 2, + }], + }, + }; + const back = formValuesToWirePayload(rawOutboundToFormValues(wire)); + expect(back.settings).toMatchObject({ + servers: [{ uot: true, UoTVersion: 2 }], + }); + }); + + it('socks emits users:[] when user is empty, users:[{...}] when set', () => { + const noUser = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'socks', + settings: { servers: [{ address: 's', port: 1080 }] }, + })); + expect(noUser.settings).toMatchObject({ servers: [{ users: [] }] }); + + const withUser = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'socks', + settings: { servers: [{ address: 's', port: 1080, users: [{ user: 'u', pass: 'p' }] }] }, + })); + expect(withUser.settings).toMatchObject({ + servers: [{ users: [{ user: 'u', pass: 'p' }] }], + }); + }); + + it('wireguard csv-joins address and reserved on read, splits on write', () => { + const wire = { + protocol: 'wireguard', + settings: { + mtu: 1420, + secretKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + address: ['10.0.0.1', 'fd00::1'], + workers: 2, + peers: [{ publicKey: 'pk', allowedIPs: ['0.0.0.0/0'], endpoint: 'e:51820', preSharedKey: 'psk' }], + reserved: [1, 2, 3], + noKernelTun: false, + }, + }; + const form = rawOutboundToFormValues(wire); + if (form.protocol === 'wireguard') { + expect(form.settings.address).toBe('10.0.0.1,fd00::1'); + expect(form.settings.reserved).toBe('1,2,3'); + expect(form.settings.peers[0].psk).toBe('psk'); + } + const back = formValuesToWirePayload(form); + expect(back.settings).toMatchObject({ + address: ['10.0.0.1', 'fd00::1'], + reserved: [1, 2, 3], + peers: [{ preSharedKey: 'psk' }], + }); + }); + + it('blackhole wraps type into {response:{type}} and omits when empty', () => { + const empty = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'blackhole', + settings: {}, + })); + expect(empty.settings).toEqual({ response: undefined }); + + const withType = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'blackhole', + settings: { response: { type: 'http' } }, + })); + expect(withType.settings).toEqual({ response: { type: 'http' } }); + }); + + it('dns rules normalize qtype numeric strings and split domains', () => { + const wire = { + protocol: 'dns', + settings: { + rewriteNetwork: 'udp', + rewriteAddress: '1.1.1.1', + rewritePort: 53, + rules: [ + { action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] }, + { action: 'reject', qtype: 28, domain: 'blocked.com' }, + ], + }, + }; + const back = formValuesToWirePayload(rawOutboundToFormValues(wire)); + const settings = back.settings as Record; + const rules = settings.rules as Array>; + expect(rules[0]).toEqual({ action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] }); + expect(rules[1]).toEqual({ action: 'reject', qtype: 28, domain: ['blocked.com'] }); + }); + + it('freedom emits domainStrategy/redirect/fragment conditionally', () => { + const empty = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'freedom', + settings: {}, + })); + expect(empty.settings).toEqual({ + domainStrategy: undefined, + redirect: undefined, + fragment: undefined, + noises: undefined, + finalRules: undefined, + }); + + const filled = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'freedom', + settings: { + domainStrategy: 'UseIPv4', + redirect: '1.1.1.1', + fragment: { packets: 'tlshello', length: '100-200' }, + }, + })); + expect(filled.settings).toMatchObject({ + domainStrategy: 'UseIPv4', + redirect: '1.1.1.1', + fragment: { packets: 'tlshello', length: '100-200' }, + }); + }); + + it('mux is only emitted when enabled AND protocol/network/flow allow it', () => { + // Disabled mux: omitted + const disabled = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'vless', + settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' }, + mux: { enabled: false }, + })); + expect(disabled).not.toHaveProperty('mux'); + + // Enabled mux on vless without flow: emitted + const enabled = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'vless', + settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' }, + mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' }, + })); + expect(enabled.mux).toMatchObject({ enabled: true }); + + // Enabled mux on vless with vision flow: gated out + const withFlow = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'vless', + settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: 'xtls-rprx-vision', encryption: 'none' }, + mux: { enabled: true }, + })); + expect(withFlow).not.toHaveProperty('mux'); + + // Freedom (non-mux protocol): gated out even if enabled + const freedom = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'freedom', + settings: {}, + mux: { enabled: true }, + })); + expect(freedom).not.toHaveProperty('mux'); + }); + + it('hysteria preserves address/port/version literal 2', () => { + const back = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'hysteria', + settings: { address: 'h.example', port: 8443, version: 2 }, + })); + expect(back.settings).toEqual({ address: 'h.example', port: 8443, version: 2 }); + }); + + it('loopback inboundTag round-trips', () => { + const back = formValuesToWirePayload(rawOutboundToFormValues({ + protocol: 'loopback', + settings: { inboundTag: 'tagged-inbound' }, + })); + expect(back.settings).toEqual({ inboundTag: 'tagged-inbound' }); + }); + + it('unknown protocol falls back to vless without throwing', () => { + const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} }); + expect(form.protocol).toBe('vless'); + }); +});