feat(frontend): outbound form schema + wire adapter foundation

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).
This commit is contained in:
MHSanaei
2026-05-26 11:58:36 +02:00
parent ec18ee4290
commit b554bb6b75
3 changed files with 1169 additions and 0 deletions

View File

@@ -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<T>. 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<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;
}
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;
}

View File

@@ -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<T>() 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<typeof VmessOutboundFormSettingsSchema>;
// 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<typeof ReverseSniffingFormSchema>;
// 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<typeof VlessOutboundFormSettingsSchema>;
export const TrojanOutboundFormSettingsSchema = z.object({
address: z.string().default(''),
port: PortSchema.default(443),
password: z.string().default(''),
});
export type TrojanOutboundFormSettings = z.infer<typeof TrojanOutboundFormSettingsSchema>;
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<typeof ShadowsocksOutboundFormSettingsSchema>;
// 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<typeof SocksOutboundFormSettingsSchema>;
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<typeof HttpOutboundFormSettingsSchema>;
// 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<typeof WireguardOutboundFormPeerSchema>;
// 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<typeof WireguardOutboundFormSettingsSchema>;
// 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<typeof HysteriaOutboundFormSettingsSchema>;
// 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<typeof FreedomFinalRuleFormSchema>;
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<typeof FreedomOutboundFormSettingsSchema>;
// 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<typeof BlackholeOutboundFormSettingsSchema>;
// DNS rules: form holds qtype + domain as joined strings (the legacy UI
// binds to <Input>). 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<typeof DnsRuleFormSchema>;
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<typeof DnsOutboundFormSettingsSchema>;
export const LoopbackOutboundFormSettingsSchema = z.object({
inboundTag: z.string().default(''),
});
export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
// inbound side: each branch is { protocol: literal, settings: <flat> }.
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<typeof OutboundFormSettingsSchema>;
// 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<typeof MuxFormSchema>;
// 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<typeof OutboundStreamFormSchema>;
// 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<typeof OutboundFormBaseSchema>;
// 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<typeof OutboundFormSchema>;

View File

@@ -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<string, unknown>;
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<string, unknown>;
const rules = settings.rules as Array<Record<string, unknown>>;
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');
});
});