mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-17 03:15:01 +03:00
62423cacd1
Bump xtls/xray-core to b26a91de4f (v26.9.30) and the three binary pins (DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted symbols; the sing and sing-shadowsocks indirect deps drop out with the SS2022 rewrite. XDNS finalmask (#6718) replaced its string lists with objects: domains are {name, types, edns0, lenLimit, labelLimit} and client resolvers {type, settings.addr}. The loader no longer parses the old lists, so a single stored xdns mask keeps the whole core from starting. The new leaf package internal/util/maskcompat converts them: "name[:type]" becomes a domain and "name[:type]+udp://addr" a domain plus a udp resolver. A bare name maps to TXT, the type legacy clients queried by default, and each converted domain keeps the 1232-byte EDNS0 the old code always used (without it the server caps answers at 512). It runs from: - the XdnsFinalmaskObjectsFix seeder, over inbound streams, hosts, the xray template, the global sub-JSON mask and cached subscription outbounds; - inbound save (normalizeStreamSettings) and GetXrayConfig, for rows that never went through the seeder; - both link importers, since fm= from an older panel carries the lists. The finalmask form edits the object shape (every key needs a registered field, or the finalmask watch drops it on save) and lifts legacy masks on open. The udp-mask golden fixture moves to the object shape, which TestGoldenStreamFixturesBuildInXray now builds through the core. The wire format changed as well, so pre-upgrade clients need the new core. WireGuard outbound (#6771) dropped settings.domainStrategy and the remoteDNS "local" mode. The endpoint lookup now follows sockopt.domainStrategy and in-tunnel targets the outbound's targetStrategy. The old key is silently ignored, which undid the IPv4-first endpoint lookup the WARP outbound depends on (#5205), and "local" now panics the core at startup because remoteDNS goes through netip.MustParseAddr. The WireguardDomainStrategyFix seeder moves a stored family preference to both keys (a value already set wins) and turns "local" into targetStrategy; the outbound form lifts legacy rows the same way and drops its select, the WARP modal writes the new placement, and the inbound form loses a field the server never read. ValidateOutboundConfig now refuses a non-IP remoteDNS entry, which conf.Build() lets through, on template save and for outbound subscriptions. Noise finalmask items accept type "exp" (#6862), a tag expression. The form offers it for noise items only: header-custom items go through the core's PraseByteSlice, which refuses it. TUN gained autoSystemDnsToGateway (Linux) and autoSystemWfpBlockLeak (Windows). Both pass through the settings schema so a value set in JSON survives the next form save. MASQUE (inbound, outbound, transport) and the XDRIVE transport are new protocols the panel does not offer yet; their new loader refusals only cover configs the panel never generates. The FakeDNS IPv6 pool default, the SS2022 rewrite (same gRPC account; emails are now deduped case-insensitively, as the panel already does), the restored udphop interval default and the rest change no panel-facing config.
995 lines
33 KiB
TypeScript
995 lines
33 KiB
TypeScript
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
|
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
|
|
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
|
|
import {
|
|
dropEmptyFinalMask,
|
|
normalizeStreamSettingsForWire,
|
|
} from '@/lib/xray/stream-wire-normalize';
|
|
import { Wireguard } from '@/utils';
|
|
import type { Sniffing, SniffingDest } from '@/schemas/primitives';
|
|
import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
|
|
|
|
import type {
|
|
AmneziaWGOutboundFormSettings,
|
|
BlackholeOutboundFormSettings,
|
|
DnsOutboundFormSettings,
|
|
DnsRuleForm,
|
|
FreedomFinalRuleForm,
|
|
FreedomOutboundFormSettings,
|
|
HttpOutboundFormSettings,
|
|
HysteriaOutboundFormSettings,
|
|
LoopbackOutboundFormSettings,
|
|
MuxForm,
|
|
OutboundFormSettings,
|
|
OutboundFormValues,
|
|
OutboundStreamFormValues,
|
|
ShadowsocksOutboundFormSettings,
|
|
TrojanOutboundFormSettings,
|
|
VlessOutboundFormSettings,
|
|
VmessOutboundFormSettings,
|
|
WireguardOutboundFormPeer,
|
|
WireguardOutboundFormSettings,
|
|
} from '@/schemas/forms/outbound-form';
|
|
|
|
type Raw = Record<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;
|
|
}
|
|
|
|
// xray-core matches targetStrategy/domainStrategy case-insensitively;
|
|
// normalize the wire value to the canonical spelling or '' (= AsIs).
|
|
function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
|
|
const s = asString(value);
|
|
if (!s) return '';
|
|
return (
|
|
OutboundDomainStrategySchema.options.find((v) => v.toLowerCase() === s.toLowerCase()) ?? ''
|
|
);
|
|
}
|
|
|
|
// Mirrors the order the loader migrates freedom's legacy strategy keys in:
|
|
// root targetStrategy, settings targetStrategy, settings domainStrategy, sockopt.
|
|
export function freedomDomainStrategyFromWire(outbound: {
|
|
targetStrategy?: unknown;
|
|
settings?: unknown;
|
|
streamSettings?: unknown;
|
|
}): OutboundDomainStrategy | '' {
|
|
const settings = asObject(outbound.settings);
|
|
const sockopt = asObject(asObject(outbound.streamSettings).sockopt);
|
|
const root = targetStrategyFromWire(outbound.targetStrategy);
|
|
const settingsKey = asString(settings.targetStrategy)
|
|
? settings.targetStrategy
|
|
: settings.domainStrategy;
|
|
const legacy = root && root !== 'AsIs' ? root : targetStrategyFromWire(settingsKey);
|
|
if (legacy && legacy !== 'AsIs') return legacy;
|
|
return targetStrategyFromWire(sockopt.domainStrategy);
|
|
}
|
|
|
|
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
|
|
|
|
const SNIFFING_DEFAULT: Sniffing = {
|
|
enabled: false,
|
|
destOverride: [...SNIFFING_DEST_VALUES],
|
|
metadataOnly: false,
|
|
routeOnly: false,
|
|
ipsExcluded: [],
|
|
domainsExcluded: [],
|
|
};
|
|
|
|
// Shared by VLESS reverse sniffing and the loopback outbound — both edit the
|
|
// same xray SniffingConfig. Unknown destOverride tokens are dropped so the
|
|
// value satisfies SniffingSchema's enum.
|
|
function sniffingFromWire(raw: unknown): Sniffing {
|
|
const r = asObject(raw);
|
|
const dest = asArray(r.destOverride)
|
|
.map((x) => asString(x))
|
|
.filter((x): x is SniffingDest => (SNIFFING_DEST_VALUES as readonly string[]).includes(x));
|
|
return {
|
|
enabled: asBool(r.enabled),
|
|
destOverride: dest.length > 0 ? dest : [...SNIFFING_DEST_VALUES],
|
|
metadataOnly: asBool(r.metadataOnly),
|
|
routeOnly: asBool(r.routeOnly),
|
|
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
|
|
domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
|
|
};
|
|
}
|
|
|
|
function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
|
|
const vnext = asArray(raw.vnext);
|
|
const v = asObject(vnext[0]);
|
|
const u = asObject(asArray(v.users)[0]);
|
|
return {
|
|
address: asString(v.address),
|
|
port: asPort(v.port, 443),
|
|
id: asString(u.id),
|
|
security: ((): VmessOutboundFormSettings['security'] => {
|
|
const s = asString(u.security);
|
|
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto'];
|
|
return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
|
|
})(),
|
|
};
|
|
}
|
|
|
|
function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
|
|
let address = asString(raw.address);
|
|
let port = asPort(raw.port, 443);
|
|
let id = asString(raw.id);
|
|
let flow = asString(raw.flow);
|
|
let encryption = asString(raw.encryption, 'none');
|
|
const vnext = asArray(raw.vnext);
|
|
if (vnext.length > 0) {
|
|
const v = asObject(vnext[0]);
|
|
const u = asObject(asArray(v.users)[0]);
|
|
address = asString(v.address);
|
|
port = asPort(v.port, 443);
|
|
id = asString(u.id);
|
|
flow = asString(u.flow);
|
|
encryption = asString(u.encryption, 'none');
|
|
}
|
|
const reverse = asObject(raw.reverse);
|
|
const reverseTag = asString(reverse.tag);
|
|
const reverseSniffing = reverseTag ? sniffingFromWire(reverse.sniffing) : SNIFFING_DEFAULT;
|
|
const savedSeed = asArray(raw.testseed);
|
|
const testseed =
|
|
savedSeed.length === 4 && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
|
? (savedSeed as number[])
|
|
: [900, 500, 900, 256];
|
|
return {
|
|
address,
|
|
port,
|
|
id,
|
|
flow,
|
|
encryption: encryption || 'none',
|
|
reverseTag,
|
|
reverseSniffing,
|
|
testpre: asNumber(raw.testpre, 0),
|
|
testseed,
|
|
};
|
|
}
|
|
|
|
function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
|
|
const s = asObject(asArray(raw.servers)[0]);
|
|
return {
|
|
address: asString(s.address),
|
|
port: asPort(s.port, 443),
|
|
password: asString(s.password),
|
|
};
|
|
}
|
|
|
|
function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
|
|
const s = asObject(asArray(raw.servers)[0]);
|
|
return {
|
|
address: asString(s.address),
|
|
port: asPort(s.port, 443),
|
|
password: asString(s.password),
|
|
method: asString(
|
|
s.method,
|
|
'2022-blake3-aes-128-gcm',
|
|
) as ShadowsocksOutboundFormSettings['method'],
|
|
uot: asBool(s.uot),
|
|
UoTVersion: asNumber(s.UoTVersion, 1),
|
|
};
|
|
}
|
|
|
|
interface SimpleAuthFormSettings {
|
|
address: string;
|
|
port: number;
|
|
user: string;
|
|
pass: string;
|
|
}
|
|
|
|
function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
|
|
const s = asObject(asArray(raw.servers)[0]);
|
|
const u = asObject(asArray(s.users)[0]);
|
|
return {
|
|
address: asString(s.address),
|
|
port: asPort(s.port, defaultPort),
|
|
user: asString(u.user),
|
|
pass: asString(u.pass),
|
|
};
|
|
}
|
|
|
|
function stringRecordFromWire(raw: unknown): Record<string, string> {
|
|
const obj = asObject(raw);
|
|
const out: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (typeof v === 'string') out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// HTTP outbound reuses the SOCKS server/user shape but also carries xray's
|
|
// top-level `settings.headers` (HTTPClientConfig.Headers), the CONNECT
|
|
// headers sent to the upstream proxy. xray ignores per-server `headers`,
|
|
// so only the settings-level map round-trips (issue #5519).
|
|
function httpFromWire(raw: Raw): HttpOutboundFormSettings {
|
|
return {
|
|
...simpleAuthFromWire(raw, 8080),
|
|
headers: stringRecordFromWire(raw.headers),
|
|
};
|
|
}
|
|
|
|
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
|
|
const secretKey = asString(raw.secretKey);
|
|
const pubKey = secretKey.length > 0 ? Wireguard.generateKeypair(secretKey).publicKey : '';
|
|
const addressArr = asArray(raw.address).map((x) =>
|
|
typeof x === 'number' ? String(x) : asString(x),
|
|
);
|
|
const reservedArr = asArray(raw.reserved).map((x) =>
|
|
typeof x === 'number' ? String(x) : asString(x),
|
|
);
|
|
const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
|
|
const pp = asObject(p);
|
|
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
|
|
return {
|
|
publicKey: asString(pp.publicKey),
|
|
psk: asString(pp.preSharedKey),
|
|
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
|
|
endpoint: asString(pp.endpoint),
|
|
keepAlive: asNumber(pp.keepAlive, 0),
|
|
};
|
|
});
|
|
return {
|
|
mtu: asNumber(raw.mtu, 1420),
|
|
secretKey,
|
|
pubKey,
|
|
address: addressArr.join(','),
|
|
reserved: reservedArr.join(','),
|
|
remoteDNS: isLegacyLocalRemoteDNS(raw.remoteDNS)
|
|
? ''
|
|
: asArray(raw.remoteDNS)
|
|
.map((x) => asString(x))
|
|
.join(','),
|
|
peers,
|
|
noKernelTun: asBool(raw.noKernelTun),
|
|
};
|
|
}
|
|
|
|
// remoteDNS ["local"] is a mode xray-core 26.9.30 removed; the core now panics parsing
|
|
// it as an address, so liftLegacyWireguardStrategy carries it over to targetStrategy.
|
|
function isLegacyLocalRemoteDNS(value: unknown): boolean {
|
|
const list = asArray(value);
|
|
return list.length === 1 && list[0] === 'local';
|
|
}
|
|
|
|
const isAsIs = (strategy: OutboundDomainStrategy | '') => strategy === '' || strategy === 'AsIs';
|
|
|
|
// xray-core 26.9.30 (#6771) ignores wireguard's settings.domainStrategy: sockopt.domainStrategy
|
|
// now picks the endpoint's family, targetStrategy the targets'. Mirrors the Go seeder.
|
|
function liftLegacyWireguardStrategy(
|
|
settings: Raw,
|
|
targetStrategy: OutboundDomainStrategy | '',
|
|
streamSettings: OutboundStreamFormValues | undefined,
|
|
): { targetStrategy: OutboundDomainStrategy | ''; streamSettings?: OutboundStreamFormValues } {
|
|
const legacy = targetStrategyFromWire(settings.domainStrategy);
|
|
const family = legacy.startsWith('ForceIP') && legacy !== 'ForceIP' ? legacy : '';
|
|
const lifted = family || (isLegacyLocalRemoteDNS(settings.remoteDNS) ? 'ForceIP' : '');
|
|
let stream = streamSettings;
|
|
const sockopt = asObject((stream as Raw | undefined)?.sockopt);
|
|
if (family && isAsIs(targetStrategyFromWire(sockopt.domainStrategy))) {
|
|
stream = {
|
|
...(stream ?? {}),
|
|
sockopt: { ...sockopt, domainStrategy: family },
|
|
} as OutboundStreamFormValues;
|
|
}
|
|
return {
|
|
targetStrategy: lifted && isAsIs(targetStrategy) ? lifted : targetStrategy,
|
|
streamSettings: stream,
|
|
};
|
|
}
|
|
|
|
function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
|
|
return {
|
|
address: asString(raw.address),
|
|
port: asPort(raw.port, 443),
|
|
version: 2,
|
|
};
|
|
}
|
|
|
|
function freedomFromWire(
|
|
raw: Raw,
|
|
domainStrategy: OutboundDomainStrategy | '',
|
|
): FreedomOutboundFormSettings {
|
|
const fragment = asObject(raw.fragment);
|
|
const noises = asArray(raw.noises).map((n) => {
|
|
const nn = asObject(n);
|
|
return {
|
|
type: asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type'],
|
|
packet: asString(nn.packet, '10-20'),
|
|
delay: asString(nn.delay, '10-16'),
|
|
applyTo: asString(
|
|
nn.applyTo,
|
|
'ip',
|
|
) as FreedomOutboundFormSettings['noises'][number]['applyTo'],
|
|
};
|
|
});
|
|
const finalRulesRaw = asArray(raw.finalRules);
|
|
const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
|
|
const rr = asObject(r);
|
|
const network = Array.isArray(rr.network)
|
|
? rr.network.map((x) => asString(x)).join(',')
|
|
: asString(rr.network);
|
|
return {
|
|
action: (asString(rr.action, 'block') === 'allow'
|
|
? 'allow'
|
|
: 'block') as FreedomFinalRuleForm['action'],
|
|
network,
|
|
port: asString(rr.port),
|
|
ip: asArray(rr.ip).map((x) => asString(x)),
|
|
blockDelay: asString(rr.blockDelay),
|
|
};
|
|
});
|
|
// Legacy ipsBlocked → finalRule(block) backfill
|
|
if (finalRules.length === 0) {
|
|
const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
|
|
if (ipsBlocked.length > 0) {
|
|
finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
|
|
}
|
|
}
|
|
// Wire fragment is either missing or a populated object. Mirror the
|
|
// legacy behavior: when the wire omits fragment, leave all four fields
|
|
// empty so the modal's "Fragment" Switch starts off. When present,
|
|
// surface whatever the wire holds verbatim.
|
|
const wireHasFragment =
|
|
raw.fragment != null && typeof raw.fragment === 'object' && Object.keys(fragment).length > 0;
|
|
return {
|
|
domainStrategy,
|
|
redirect: asString(raw.redirect),
|
|
userLevel: asNumber(raw.userLevel, 0),
|
|
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
|
const n = asNumber(raw.proxyProtocol, 0);
|
|
return n === 1 || n === 2 ? n : 0;
|
|
})(),
|
|
fragment: wireHasFragment
|
|
? {
|
|
packets: asString(fragment.packets, '1-3'),
|
|
length: asString(fragment.length),
|
|
interval: asString(fragment.interval),
|
|
maxSplit: asString(fragment.maxSplit),
|
|
}
|
|
: { packets: '', length: '', interval: '', maxSplit: '' },
|
|
noises,
|
|
finalRules,
|
|
};
|
|
}
|
|
|
|
function blackholeFromWire(raw: Raw): BlackholeOutboundFormSettings {
|
|
const response = asObject(raw.response);
|
|
const t = asString(response.type);
|
|
return {
|
|
type: t === 'none' || t === 'http' || t === 'custom' ? t : '',
|
|
customResponseData: asString(response.customResponseData),
|
|
};
|
|
}
|
|
|
|
function dnsRuleFromWire(raw: unknown): DnsRuleForm {
|
|
const r = asObject(raw);
|
|
const rawQType = r.qType ?? r.qtype;
|
|
const qType = Array.isArray(rawQType)
|
|
? rawQType.map((x) => String(x)).join(',')
|
|
: typeof rawQType === 'number'
|
|
? String(rawQType)
|
|
: asString(rawQType);
|
|
const domain = Array.isArray(r.domain)
|
|
? r.domain.map((x) => asString(x)).join(',')
|
|
: asString(r.domain);
|
|
const action = asString(r.action, 'direct');
|
|
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action) ? action : 'direct';
|
|
return {
|
|
action: validAction as DnsRuleForm['action'],
|
|
qType,
|
|
domain,
|
|
rCode: asNumber(r.rCode, 0),
|
|
};
|
|
}
|
|
|
|
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
|
const rules = asArray(raw.rules).map(dnsRuleFromWire);
|
|
return {
|
|
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
|
|
const s = asString(raw.rewriteNetwork ?? raw.network);
|
|
return s === 'udp' || s === 'tcp' ? s : '';
|
|
})(),
|
|
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
|
|
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
|
|
userLevel: asNumber(raw.userLevel, 0),
|
|
rules,
|
|
};
|
|
}
|
|
|
|
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
|
|
return {
|
|
inboundTag: asString(raw.inboundTag),
|
|
sniffing: sniffingFromWire(raw.sniffing),
|
|
};
|
|
}
|
|
|
|
function amneziawgPeerFromWire(p: unknown): AmneziaWGOutboundFormSettings['peers'][number] {
|
|
const pp = asObject(p);
|
|
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
|
|
return {
|
|
publicKey: asString(pp.publicKey),
|
|
presharedKey: asString(pp.presharedKey),
|
|
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
|
|
endpoint: asString(pp.endpoint),
|
|
keepAlive: asNumber(pp.keepAlive, 0),
|
|
};
|
|
}
|
|
|
|
// The form state IS the wire shape; hydrate only to apply defaults for keys
|
|
// an older template may omit.
|
|
function amneziawgFromWire(raw: Raw): AmneziaWGOutboundFormSettings {
|
|
return AmneziaWGOutboundSettingsSchema.parse({
|
|
mtu: asNumber(raw.mtu, 0),
|
|
secretKey: asString(raw.secretKey),
|
|
address: asArray(raw.address).map((x) => asString(x)),
|
|
listenPort: asNumber(raw.listenPort, 0),
|
|
dns: asString(raw.dns),
|
|
jc: asNumber(raw.jc, 0),
|
|
jmin: asNumber(raw.jmin, 40),
|
|
jmax: asNumber(raw.jmax, 100),
|
|
s1: asNumber(raw.s1, 15),
|
|
s2: asNumber(raw.s2, 80),
|
|
s3: asNumber(raw.s3, 12),
|
|
s4: asNumber(raw.s4, 12),
|
|
h1: asString(raw.h1),
|
|
h2: asString(raw.h2),
|
|
h3: asString(raw.h3),
|
|
h4: asString(raw.h4),
|
|
i1: asString(raw.i1),
|
|
i2: asString(raw.i2),
|
|
i3: asString(raw.i3),
|
|
i4: asString(raw.i4),
|
|
i5: asString(raw.i5),
|
|
headerProtectionKey: asString(raw.headerProtectionKey),
|
|
contentPaddingAddition: asString(raw.contentPaddingAddition),
|
|
rekeyAfterTime: asString(raw.rekeyAfterTime),
|
|
rekeyTimeout: asString(raw.rekeyTimeout),
|
|
rejectAfterTime: asString(raw.rejectAfterTime),
|
|
keepaliveTimeout: asString(raw.keepaliveTimeout),
|
|
maxHandshakeAttempts: asString(raw.maxHandshakeAttempts),
|
|
randomTrailers: raw.randomTrailers === undefined ? false : asBool(raw.randomTrailers),
|
|
disableCookies: raw.disableCookies === undefined ? true : asBool(raw.disableCookies),
|
|
peers: asArray(raw.peers).map(amneziawgPeerFromWire),
|
|
});
|
|
}
|
|
|
|
function amneziawgToWire(s: AmneziaWGOutboundFormSettings): Raw {
|
|
const out: Raw = {
|
|
mtu: s.mtu || undefined,
|
|
secretKey: s.secretKey,
|
|
address: s.address,
|
|
jc: s.jc,
|
|
jmin: s.jmin,
|
|
jmax: s.jmax,
|
|
s1: s.s1,
|
|
s2: s.s2,
|
|
s3: s.s3,
|
|
s4: s.s4,
|
|
h1: s.h1,
|
|
h2: s.h2,
|
|
h3: s.h3,
|
|
h4: s.h4,
|
|
randomTrailers: s.randomTrailers,
|
|
disableCookies: s.disableCookies,
|
|
peers: s.peers.map((p) => ({
|
|
publicKey: p.publicKey,
|
|
presharedKey: p.presharedKey.length > 0 ? p.presharedKey : undefined,
|
|
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
|
|
endpoint: p.endpoint,
|
|
keepAlive: p.keepAlive || undefined,
|
|
})),
|
|
};
|
|
if (s.listenPort > 0) out.listenPort = s.listenPort;
|
|
if (s.dns && s.dns.length > 0) out.dns = s.dns;
|
|
const optionalStrings = [
|
|
'i1',
|
|
'i2',
|
|
'i3',
|
|
'i4',
|
|
'i5',
|
|
'headerProtectionKey',
|
|
'contentPaddingAddition',
|
|
'rekeyAfterTime',
|
|
'rekeyTimeout',
|
|
'rejectAfterTime',
|
|
'keepaliveTimeout',
|
|
'maxHandshakeAttempts',
|
|
] as const;
|
|
for (const k of optionalStrings) {
|
|
if (s[k].length > 0) out[k] = s[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function muxFromWire(raw: unknown): MuxForm {
|
|
const m = asObject(raw);
|
|
return {
|
|
enabled: asBool(m.enabled),
|
|
concurrency: asNumber(m.concurrency, 8),
|
|
xudpConcurrency: asNumber(m.xudpConcurrency, 16),
|
|
xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
|
|
const s = asString(m.xudpProxyUDP443, 'reject');
|
|
return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
|
|
})(),
|
|
};
|
|
}
|
|
|
|
export interface RawOutboundRow {
|
|
tag?: string;
|
|
protocol?: string;
|
|
sendThrough?: string;
|
|
targetStrategy?: string;
|
|
settings?: unknown;
|
|
streamSettings?: unknown;
|
|
mux?: unknown;
|
|
}
|
|
|
|
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
|
|
|
function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
|
|
const next = { ...stream };
|
|
if (typeof next.method === 'string' && next.method !== '') {
|
|
next.network = next.method;
|
|
}
|
|
delete next.method;
|
|
const xh = next.xhttpSettings;
|
|
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
|
|
const xhttp = { ...(xh as Raw) };
|
|
const xmux = xhttp.xmux;
|
|
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
|
|
xhttp.enableXmux = true;
|
|
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Raw) };
|
|
}
|
|
next.xhttpSettings = xhttp;
|
|
}
|
|
return next as unknown as OutboundStreamFormValues;
|
|
}
|
|
|
|
export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
|
|
// The core lowercases a protocol id before it looks the handler up, so a
|
|
// template pasted as "Freedom" must not fall through to the vless default.
|
|
const protocol = asString(raw.protocol, 'vless').toLowerCase();
|
|
const settings = asObject(raw.settings);
|
|
const tag = asString(raw.tag);
|
|
const sendThrough = asString(raw.sendThrough);
|
|
const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
|
|
const freedomStrategy = freedomDomainStrategyFromWire(raw);
|
|
const mux = muxFromWire(raw.mux);
|
|
const hasStream =
|
|
raw.streamSettings &&
|
|
typeof raw.streamSettings === 'object' &&
|
|
Object.keys(raw.streamSettings as Raw).length > 0;
|
|
const streamSettings = hasStream ? hydrateStreamForm(raw.streamSettings as Raw) : undefined;
|
|
|
|
let typed: OutboundFormSettings;
|
|
switch (protocol) {
|
|
case 'vmess':
|
|
typed = { protocol: 'vmess', settings: vmessFromWire(settings) };
|
|
break;
|
|
case 'vless':
|
|
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
|
break;
|
|
case 'trojan':
|
|
typed = { protocol: 'trojan', settings: trojanFromWire(settings) };
|
|
break;
|
|
case 'shadowsocks':
|
|
typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) };
|
|
break;
|
|
case 'socks':
|
|
typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) };
|
|
break;
|
|
case 'http':
|
|
typed = { protocol: 'http', settings: httpFromWire(settings) };
|
|
break;
|
|
case 'wireguard':
|
|
typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
|
|
break;
|
|
case 'amneziawg':
|
|
typed = { protocol: 'amneziawg', settings: amneziawgFromWire(settings) };
|
|
break;
|
|
case 'hysteria':
|
|
typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
|
|
break;
|
|
case 'freedom':
|
|
typed = {
|
|
protocol: 'freedom',
|
|
settings: freedomFromWire(settings, freedomStrategy),
|
|
};
|
|
break;
|
|
case 'blackhole':
|
|
typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) };
|
|
break;
|
|
case 'dns':
|
|
typed = { protocol: 'dns', settings: dnsFromWire(settings) };
|
|
break;
|
|
case 'loopback':
|
|
typed = { protocol: 'loopback', settings: loopbackFromWire(settings) };
|
|
break;
|
|
default:
|
|
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
|
}
|
|
|
|
const placed =
|
|
protocol === 'wireguard'
|
|
? liftLegacyWireguardStrategy(settings, targetStrategy, streamSettings)
|
|
: { targetStrategy, streamSettings };
|
|
|
|
return {
|
|
...typed,
|
|
tag,
|
|
sendThrough,
|
|
// The freedom card owns the strategy for freedom, so the shared root field
|
|
// stays empty and cannot disagree with what the card is showing.
|
|
targetStrategy: protocol === 'freedom' ? '' : placed.targetStrategy,
|
|
mux,
|
|
streamSettings: placed.streamSettings,
|
|
};
|
|
}
|
|
|
|
// --- Form values -> wire payload --------------------------------------
|
|
|
|
function vmessToWire(s: VmessOutboundFormSettings) {
|
|
return {
|
|
vnext: [
|
|
{
|
|
address: s.address,
|
|
port: s.port,
|
|
users: [{ id: s.id, security: s.security }],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function sniffingToWire(s: Sniffing) {
|
|
return {
|
|
enabled: s.enabled,
|
|
destOverride: s.destOverride,
|
|
metadataOnly: s.metadataOnly,
|
|
routeOnly: s.routeOnly,
|
|
ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
|
|
domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
|
|
};
|
|
}
|
|
|
|
function vlessToWire(s: VlessOutboundFormSettings) {
|
|
const result: Raw = {
|
|
address: s.address,
|
|
port: s.port,
|
|
id: s.id,
|
|
flow: s.flow,
|
|
encryption: s.encryption || 'none',
|
|
};
|
|
if (s.reverseTag) {
|
|
const sn = sniffingToWire(s.reverseSniffing);
|
|
const defaultSn = sniffingToWire(SNIFFING_DEFAULT);
|
|
result.reverse = {
|
|
tag: s.reverseTag,
|
|
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
|
|
};
|
|
}
|
|
if (s.flow === 'xtls-rprx-vision') {
|
|
if (s.testpre > 0) result.testpre = s.testpre;
|
|
if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
|
|
result.testseed = s.testseed;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function trojanToWire(s: TrojanOutboundFormSettings) {
|
|
return { servers: [{ address: s.address, port: s.port, password: s.password }] };
|
|
}
|
|
|
|
function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
|
|
return {
|
|
servers: [
|
|
{
|
|
address: s.address,
|
|
port: s.port,
|
|
password: s.password,
|
|
method: s.method,
|
|
uot: s.uot,
|
|
UoTVersion: s.UoTVersion,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function simpleAuthToWire(s: SimpleAuthFormSettings) {
|
|
return {
|
|
servers: [
|
|
{
|
|
address: s.address,
|
|
port: s.port,
|
|
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function httpToWire(s: HttpOutboundFormSettings): Raw {
|
|
const wire: Raw = simpleAuthToWire(s);
|
|
if (s.headers && Object.keys(s.headers).length > 0) {
|
|
wire.headers = s.headers;
|
|
}
|
|
return wire;
|
|
}
|
|
|
|
function wireguardToWire(s: WireguardOutboundFormSettings) {
|
|
return {
|
|
mtu: s.mtu || undefined,
|
|
secretKey: s.secretKey,
|
|
address: s.address
|
|
? s.address
|
|
.split(',')
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
: [],
|
|
reserved: s.reserved
|
|
? s.reserved
|
|
.split(',')
|
|
.map((x) => Number(x.trim()))
|
|
.filter((n) => Number.isFinite(n))
|
|
: undefined,
|
|
remoteDNS: s.remoteDNS
|
|
? s.remoteDNS
|
|
.split(',')
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
: undefined,
|
|
peers: s.peers.map((p) => ({
|
|
publicKey: p.publicKey,
|
|
preSharedKey: p.psk.length > 0 ? p.psk : undefined,
|
|
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
|
|
endpoint: p.endpoint,
|
|
keepAlive: p.keepAlive || undefined,
|
|
})),
|
|
noKernelTun: s.noKernelTun,
|
|
};
|
|
}
|
|
|
|
function hysteriaToWire(s: HysteriaOutboundFormSettings) {
|
|
return { address: s.address, port: s.port, version: s.version };
|
|
}
|
|
|
|
function freedomToWire(s: FreedomOutboundFormSettings) {
|
|
// Legacy semantics: emit fragment only when the user actually populated
|
|
// at least one of the four sub-fields. Defaults like packets='1-3' alone
|
|
// are not enough — the modal's Fragment Switch sets all four together.
|
|
// getFieldsValue(true) may omit `fragment` when the switch is off, so the
|
|
// fallback keeps Object.entries from throwing on undefined (issue #4686).
|
|
const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
|
|
const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
|
|
const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
|
|
// domainStrategy is absent here on purpose: formValuesToWirePayload hoists it
|
|
// into streamSettings.sockopt, the only placement freedom resolves with.
|
|
return {
|
|
redirect: s.redirect || undefined,
|
|
userLevel: s.userLevel || undefined,
|
|
proxyProtocol: s.proxyProtocol || undefined,
|
|
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
|
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
|
finalRules:
|
|
s.finalRules && s.finalRules.length > 0
|
|
? s.finalRules.map((r) => ({
|
|
action: r.action,
|
|
network: r.network || undefined,
|
|
port: r.port || undefined,
|
|
ip: r.ip.length > 0 ? r.ip : undefined,
|
|
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
|
}))
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function blackholeToWire(s: BlackholeOutboundFormSettings) {
|
|
if (!s.type) return { response: undefined };
|
|
if (s.type === 'custom') {
|
|
return { response: { type: s.type, customResponseData: s.customResponseData } };
|
|
}
|
|
return { response: { type: s.type } };
|
|
}
|
|
|
|
function dnsRuleToWire(r: DnsRuleForm) {
|
|
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action) ? r.action : 'direct';
|
|
const result: Raw = { action };
|
|
const qType = r.qType.trim();
|
|
if (qType) {
|
|
// The core reads a numeric 0 as no qType at all, which matches every query.
|
|
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
|
|
}
|
|
const domains = r.domain
|
|
.split(',')
|
|
.map((d) => d.trim())
|
|
.filter(Boolean);
|
|
if (domains.length > 0) result.domain = domains;
|
|
if (r.rCode > 0) result.rCode = r.rCode;
|
|
return result;
|
|
}
|
|
|
|
function dnsToWire(s: DnsOutboundFormSettings) {
|
|
const result: Raw = {};
|
|
if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
|
|
if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
|
|
if (s.rewritePort) result.rewritePort = s.rewritePort;
|
|
if (s.userLevel) result.userLevel = s.userLevel;
|
|
if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
|
|
return result;
|
|
}
|
|
|
|
function loopbackToWire(s: LoopbackOutboundFormSettings) {
|
|
const result: Raw = { inboundTag: s.inboundTag || undefined };
|
|
// Sniffing rides only when enabled — a disabled block is a no-op for
|
|
// xray's BuildSniffingRequest, so omitting it keeps the wire minimal.
|
|
if (s.sniffing.enabled) {
|
|
result.sniffing = sniffingToWire(s.sniffing);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// canEnableMux mirrors the legacy Outbound.canEnableMux().
|
|
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
|
|
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
|
|
|
|
function dropEmptyStrings(obj: Raw): Raw {
|
|
const out: Raw = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (v === '') continue;
|
|
out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function stripUiOnlyStreamFields(stream: unknown): Raw {
|
|
const next = { ...(stream as Raw) };
|
|
const xh = next.xhttpSettings;
|
|
if (xh && typeof xh === 'object') {
|
|
const cleaned = { ...(xh as Raw) };
|
|
const xmuxEnabled = cleaned.enableXmux === true;
|
|
delete cleaned.enableXmux;
|
|
if (!xmuxEnabled) delete cleaned.xmux;
|
|
next.xhttpSettings = dropEmptyStrings(cleaned);
|
|
}
|
|
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
|
|
}
|
|
|
|
function muxAllowed(values: OutboundFormValues): boolean {
|
|
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
|
const flow =
|
|
values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : '';
|
|
if (flow) return false;
|
|
const network =
|
|
values.streamSettings && 'network' in values.streamSettings
|
|
? values.streamSettings.network
|
|
: undefined;
|
|
if (network === 'xhttp') return false;
|
|
return true;
|
|
}
|
|
|
|
export type WireOutboundPayload = Raw;
|
|
|
|
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
|
|
let settings: Raw;
|
|
switch (values.protocol) {
|
|
case 'vmess':
|
|
settings = vmessToWire(values.settings);
|
|
break;
|
|
case 'vless':
|
|
settings = vlessToWire(values.settings);
|
|
break;
|
|
case 'trojan':
|
|
settings = trojanToWire(values.settings);
|
|
break;
|
|
case 'shadowsocks':
|
|
settings = shadowsocksToWire(values.settings);
|
|
break;
|
|
case 'socks':
|
|
settings = simpleAuthToWire(values.settings);
|
|
break;
|
|
case 'http':
|
|
settings = httpToWire(values.settings);
|
|
break;
|
|
case 'wireguard':
|
|
settings = wireguardToWire(values.settings);
|
|
break;
|
|
case 'amneziawg':
|
|
settings = amneziawgToWire(values.settings);
|
|
break;
|
|
case 'hysteria':
|
|
settings = hysteriaToWire(values.settings);
|
|
break;
|
|
case 'freedom':
|
|
settings = freedomToWire(values.settings);
|
|
break;
|
|
case 'blackhole':
|
|
settings = blackholeToWire(values.settings);
|
|
break;
|
|
case 'dns':
|
|
settings = dnsToWire(values.settings);
|
|
break;
|
|
case 'loopback':
|
|
settings = loopbackToWire(values.settings);
|
|
break;
|
|
}
|
|
|
|
const result: Raw = {
|
|
protocol: values.protocol,
|
|
settings,
|
|
};
|
|
if (values.tag) result.tag = values.tag;
|
|
if (values.targetStrategy && values.protocol !== 'freedom') {
|
|
result.targetStrategy = values.targetStrategy;
|
|
}
|
|
|
|
// Non-stream protocols emit only `sockopt`; wireguard also keeps `finalmask`, which the
|
|
// core dials its peer through (the one non-stream protocol the mask editor renders for).
|
|
if (values.streamSettings) {
|
|
if (STREAM_PROTOCOLS.has(values.protocol)) {
|
|
result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
|
|
} else {
|
|
const { sockopt, finalmask } = values.streamSettings as {
|
|
sockopt?: unknown;
|
|
finalmask?: unknown;
|
|
};
|
|
const stream: Raw = {};
|
|
if (sockopt) stream.sockopt = sockopt;
|
|
if (values.protocol === 'wireguard' && finalmask && typeof finalmask === 'object') {
|
|
stream.finalmask = { ...(finalmask as Raw) };
|
|
dropEmptyFinalMask(stream);
|
|
}
|
|
if (Object.keys(stream).length > 0) result.streamSettings = stream;
|
|
}
|
|
}
|
|
|
|
// Freedom only honours sockopt.domainStrategy; the root and settings keys are
|
|
// legacy aliases the loader warns about on every start (infra/conf/xray.go).
|
|
if (values.protocol === 'freedom') {
|
|
const stream = (result.streamSettings ?? {}) as Raw;
|
|
const sockopt = asObject(stream.sockopt);
|
|
const strategy = values.settings.domainStrategy || values.targetStrategy;
|
|
if (strategy && strategy !== 'AsIs') sockopt.domainStrategy = strategy;
|
|
else delete sockopt.domainStrategy;
|
|
if (Object.keys(sockopt).length > 0) stream.sockopt = sockopt;
|
|
else delete stream.sockopt;
|
|
if (Object.keys(stream).length > 0) result.streamSettings = stream;
|
|
else delete result.streamSettings;
|
|
}
|
|
|
|
if (values.sendThrough) result.sendThrough = values.sendThrough;
|
|
// mux may be absent when the modal didn't render the Mux switch (non-
|
|
// stream protocols or when isMuxAllowed gated it out). validateFields()
|
|
// only returns registered fields, so values.mux can be undefined.
|
|
if (values.mux?.enabled && muxAllowed(values)) {
|
|
result.mux = values.mux;
|
|
}
|
|
return result;
|
|
}
|