mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-28 16:09:36 +00:00
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:
602
frontend/src/lib/xray/outbound-form-adapter.ts
Normal file
602
frontend/src/lib/xray/outbound-form-adapter.ts
Normal 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;
|
||||
}
|
||||
265
frontend/src/schemas/forms/outbound-form.ts
Normal file
265
frontend/src/schemas/forms/outbound-form.ts
Normal 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>;
|
||||
302
frontend/src/test/outbound-form-adapter.test.ts
Normal file
302
frontend/src/test/outbound-form-adapter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user