mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-06 06:02:09 +03:00
6ed6f57b5c
* fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults to 0, split server/client Reality fields on save, validate target host:port in the inbound form, and expose Happy Eyeballs for the direct freedom outbound. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(panel): keep REALITY public key on the wire, guard freedom noises The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload. Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler. Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
653 lines
22 KiB
TypeScript
653 lines
22 KiB
TypeScript
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
|
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';
|
|
|
|
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[])
|
|
: [900, 500, 900, 256];
|
|
return {
|
|
address,
|
|
port,
|
|
id,
|
|
flow,
|
|
encryption: encryption || 'none',
|
|
reverseTag,
|
|
reverseSniffing,
|
|
testpre: asNumber(raw.testpre, 0),
|
|
testseed,
|
|
};
|
|
}
|
|
|
|
function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
|
|
const s = asObject(asArray(raw.servers)[0]);
|
|
return {
|
|
address: asString(s.address),
|
|
port: asPort(s.port, 443),
|
|
password: asString(s.password),
|
|
};
|
|
}
|
|
|
|
function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
|
|
const s = asObject(asArray(raw.servers)[0]);
|
|
return {
|
|
address: asString(s.address),
|
|
port: asPort(s.port, 443),
|
|
password: asString(s.password),
|
|
method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
|
|
uot: asBool(s.uot),
|
|
UoTVersion: asNumber(s.UoTVersion, 1),
|
|
};
|
|
}
|
|
|
|
interface SimpleAuthFormSettings {
|
|
address: string;
|
|
port: number;
|
|
user: string;
|
|
pass: string;
|
|
}
|
|
|
|
function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
|
|
const s = asObject(asArray(raw.servers)[0]);
|
|
const u = asObject(asArray(s.users)[0]);
|
|
return {
|
|
address: asString(s.address),
|
|
port: asPort(s.port, defaultPort),
|
|
user: asString(u.user),
|
|
pass: asString(u.pass),
|
|
};
|
|
}
|
|
|
|
function 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),
|
|
userLevel: asNumber(raw.userLevel, 0),
|
|
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
|
const n = asNumber(raw.proxyProtocol, 0);
|
|
return (n === 1 || n === 2) ? n : 0;
|
|
})(),
|
|
fragment: wireHasFragment
|
|
? {
|
|
packets: asString(fragment.packets, '1-3'),
|
|
length: asString(fragment.length),
|
|
interval: asString(fragment.interval),
|
|
maxSplit: asString(fragment.maxSplit),
|
|
}
|
|
: { packets: '', length: '', interval: '', maxSplit: '' },
|
|
noises,
|
|
finalRules,
|
|
};
|
|
}
|
|
|
|
function blackholeFromWire(raw: Raw) {
|
|
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 rawQType = r.qType ?? r.qtype;
|
|
const qType = Array.isArray(rawQType)
|
|
? rawQType.map((x) => String(x)).join(',')
|
|
: typeof rawQType === 'number'
|
|
? String(rawQType)
|
|
: asString(rawQType);
|
|
const domain = Array.isArray(r.domain)
|
|
? r.domain.map((x) => asString(x)).join(',')
|
|
: asString(r.domain);
|
|
const action = asString(r.action, 'direct');
|
|
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action)
|
|
? action
|
|
: 'direct';
|
|
return { action: validAction as DnsRuleForm['action'], qType, domain, rCode: asNumber(r.rCode, 0) };
|
|
}
|
|
|
|
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
|
const rules = asArray(raw.rules).map(dnsRuleFromWire);
|
|
return {
|
|
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
|
|
const s = asString(raw.rewriteNetwork ?? raw.network);
|
|
return (s === 'udp' || s === 'tcp') ? s : '';
|
|
})(),
|
|
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
|
|
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
|
|
userLevel: asNumber(raw.userLevel, 0),
|
|
rules,
|
|
};
|
|
}
|
|
|
|
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
|
|
return { inboundTag: asString(raw.inboundTag) };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
|
|
|
function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
|
|
const next = { ...stream };
|
|
const xh = next.xhttpSettings;
|
|
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
|
|
const xhttp = { ...(xh as Raw) };
|
|
const xmux = xhttp.xmux;
|
|
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
|
|
xhttp.enableXmux = true;
|
|
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Raw) };
|
|
}
|
|
next.xhttpSettings = xhttp;
|
|
}
|
|
return next as unknown as OutboundStreamFormValues;
|
|
}
|
|
|
|
export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
|
|
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 hasStream = raw.streamSettings
|
|
&& typeof raw.streamSettings === 'object'
|
|
&& Object.keys(raw.streamSettings as Raw).length > 0;
|
|
const streamSettings = hasStream
|
|
? hydrateStreamForm(raw.streamSettings as Raw)
|
|
: undefined;
|
|
|
|
let typed: OutboundFormSettings;
|
|
switch (protocol) {
|
|
case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
|
|
case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
|
|
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
|
|
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
|
|
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
|
|
case 'http': typed = { protocol: 'http', settings: 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.
|
|
// getFieldsValue(true) may omit `fragment` when the switch is off, so the
|
|
// fallback keeps Object.entries from throwing on undefined (issue #4686).
|
|
const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
|
|
const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
|
|
const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
|
|
return {
|
|
domainStrategy: s.domainStrategy || undefined,
|
|
redirect: s.redirect || undefined,
|
|
userLevel: s.userLevel || undefined,
|
|
proxyProtocol: s.proxyProtocol || undefined,
|
|
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
|
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
|
finalRules: s.finalRules && s.finalRules.length > 0
|
|
? s.finalRules.map((r) => ({
|
|
action: r.action,
|
|
network: r.network || undefined,
|
|
port: r.port || undefined,
|
|
ip: r.ip.length > 0 ? r.ip : undefined,
|
|
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
|
}))
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
|
|
return { response: s.type ? { type: s.type } : undefined };
|
|
}
|
|
|
|
function dnsRuleToWire(r: DnsRuleForm) {
|
|
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action)
|
|
? r.action
|
|
: 'direct';
|
|
const result: Raw = { action };
|
|
const qType = r.qType.trim();
|
|
if (qType) {
|
|
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;
|
|
if (r.rCode > 0) result.rCode = r.rCode;
|
|
return result;
|
|
}
|
|
|
|
function dnsToWire(s: DnsOutboundFormSettings) {
|
|
const result: Raw = {};
|
|
if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
|
|
if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
|
|
if (s.rewritePort) result.rewritePort = s.rewritePort;
|
|
if (s.userLevel) result.userLevel = s.userLevel;
|
|
if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
|
|
return result;
|
|
}
|
|
|
|
function loopbackToWire(s: LoopbackOutboundFormSettings) {
|
|
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 dropEmptyStrings(obj: Raw): Raw {
|
|
const out: Raw = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (v === '') continue;
|
|
out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function stripUiOnlyStreamFields(stream: unknown): Raw {
|
|
const next = { ...(stream as Raw) };
|
|
const xh = next.xhttpSettings;
|
|
if (xh && typeof xh === 'object') {
|
|
const cleaned = { ...(xh as Raw) };
|
|
const xmuxEnabled = cleaned.enableXmux === true;
|
|
delete cleaned.enableXmux;
|
|
if (!xmuxEnabled) delete cleaned.xmux;
|
|
next.xhttpSettings = dropEmptyStrings(cleaned);
|
|
}
|
|
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
|
|
}
|
|
|
|
function muxAllowed(values: OutboundFormValues): boolean {
|
|
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
|
const flow = values.protocol === 'vless'
|
|
? (values.settings as VlessOutboundFormSettings).flow
|
|
: '';
|
|
if (flow) return false;
|
|
const network = values.streamSettings && 'network' in values.streamSettings
|
|
? values.streamSettings.network
|
|
: undefined;
|
|
if (network === 'xhttp') return false;
|
|
return true;
|
|
}
|
|
|
|
export type WireOutboundPayload = Raw;
|
|
|
|
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
|
|
let settings: Raw;
|
|
switch (values.protocol) {
|
|
case 'vmess': settings = vmessToWire(values.settings); break;
|
|
case 'vless': settings = vlessToWire(values.settings); break;
|
|
case 'trojan': settings = trojanToWire(values.settings); break;
|
|
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
|
|
case 'socks': settings = simpleAuthToWire(values.settings); break;
|
|
case 'http': settings = 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 = stripUiOnlyStreamFields(values.streamSettings);
|
|
} else {
|
|
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
|
|
if (sockopt) result.streamSettings = { sockopt };
|
|
}
|
|
}
|
|
|
|
if (values.sendThrough) result.sendThrough = values.sendThrough;
|
|
// mux may be absent when the modal didn't render the Mux switch (non-
|
|
// stream protocols or when isMuxAllowed gated it out). validateFields()
|
|
// only returns registered fields, so values.mux can be undefined.
|
|
if (values.mux?.enabled && muxAllowed(values)) {
|
|
result.mux = values.mux;
|
|
}
|
|
return result;
|
|
}
|