outbound-form-adapter.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
  2. import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
  3. import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
  4. import { Wireguard } from '@/utils';
  5. import type { Sniffing, SniffingDest } from '@/schemas/primitives';
  6. import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
  7. import type {
  8. DnsOutboundFormSettings,
  9. DnsRuleForm,
  10. FreedomFinalRuleForm,
  11. FreedomOutboundFormSettings,
  12. HttpOutboundFormSettings,
  13. HysteriaOutboundFormSettings,
  14. LoopbackOutboundFormSettings,
  15. MuxForm,
  16. OutboundFormSettings,
  17. OutboundFormValues,
  18. OutboundStreamFormValues,
  19. ShadowsocksOutboundFormSettings,
  20. TrojanOutboundFormSettings,
  21. VlessOutboundFormSettings,
  22. VmessOutboundFormSettings,
  23. WireguardOutboundFormPeer,
  24. WireguardOutboundFormSettings,
  25. } from '@/schemas/forms/outbound-form';
  26. type Raw = Record<string, unknown>;
  27. function asObject(value: unknown): Raw {
  28. return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
  29. }
  30. function asArray(value: unknown): unknown[] {
  31. return Array.isArray(value) ? value : [];
  32. }
  33. function asString(value: unknown, fallback = ''): string {
  34. return typeof value === 'string' ? value : fallback;
  35. }
  36. function asNumber(value: unknown, fallback = 0): number {
  37. if (typeof value === 'number' && Number.isFinite(value)) return value;
  38. if (typeof value === 'string' && value.trim() !== '') {
  39. const n = Number(value);
  40. return Number.isFinite(n) ? n : fallback;
  41. }
  42. return fallback;
  43. }
  44. function asBool(value: unknown): boolean {
  45. return value === true;
  46. }
  47. function asPort(value: unknown, fallback: number): number {
  48. const n = asNumber(value, fallback);
  49. if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
  50. return n;
  51. }
  52. // xray-core matches targetStrategy/domainStrategy case-insensitively;
  53. // normalize the wire value to the canonical spelling or '' (= AsIs).
  54. function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
  55. const s = asString(value);
  56. if (!s) return '';
  57. return OutboundDomainStrategySchema.options.find(
  58. (v) => v.toLowerCase() === s.toLowerCase(),
  59. ) ?? '';
  60. }
  61. const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
  62. const SNIFFING_DEFAULT: Sniffing = {
  63. enabled: false,
  64. destOverride: [...SNIFFING_DEST_VALUES],
  65. metadataOnly: false,
  66. routeOnly: false,
  67. ipsExcluded: [],
  68. domainsExcluded: [],
  69. };
  70. // Shared by VLESS reverse sniffing and the loopback outbound — both edit the
  71. // same xray SniffingConfig. Unknown destOverride tokens are dropped so the
  72. // value satisfies SniffingSchema's enum.
  73. function sniffingFromWire(raw: unknown): Sniffing {
  74. const r = asObject(raw);
  75. const dest = asArray(r.destOverride)
  76. .map((x) => asString(x))
  77. .filter((x): x is SniffingDest => (SNIFFING_DEST_VALUES as readonly string[]).includes(x));
  78. return {
  79. enabled: asBool(r.enabled),
  80. destOverride: dest.length > 0 ? dest : [...SNIFFING_DEST_VALUES],
  81. metadataOnly: asBool(r.metadataOnly),
  82. routeOnly: asBool(r.routeOnly),
  83. ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
  84. domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
  85. };
  86. }
  87. function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
  88. const vnext = asArray(raw.vnext);
  89. const v = asObject(vnext[0]);
  90. const u = asObject(asArray(v.users)[0]);
  91. return {
  92. address: asString(v.address),
  93. port: asPort(v.port, 443),
  94. id: asString(u.id),
  95. security: ((): VmessOutboundFormSettings['security'] => {
  96. const s = asString(u.security);
  97. const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
  98. return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
  99. })(),
  100. };
  101. }
  102. function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
  103. let address = asString(raw.address);
  104. let port = asPort(raw.port, 443);
  105. let id = asString(raw.id);
  106. let flow = asString(raw.flow);
  107. let encryption = asString(raw.encryption, 'none');
  108. const vnext = asArray(raw.vnext);
  109. if (vnext.length > 0) {
  110. const v = asObject(vnext[0]);
  111. const u = asObject(asArray(v.users)[0]);
  112. address = asString(v.address);
  113. port = asPort(v.port, 443);
  114. id = asString(u.id);
  115. flow = asString(u.flow);
  116. encryption = asString(u.encryption, 'none');
  117. }
  118. const reverse = asObject(raw.reverse);
  119. const reverseTag = asString(reverse.tag);
  120. const reverseSniffing = reverseTag
  121. ? sniffingFromWire(reverse.sniffing)
  122. : SNIFFING_DEFAULT;
  123. const savedSeed = asArray(raw.testseed);
  124. const testseed = savedSeed.length === 4
  125. && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
  126. ? (savedSeed as number[])
  127. : [900, 500, 900, 256];
  128. return {
  129. address,
  130. port,
  131. id,
  132. flow,
  133. encryption: encryption || 'none',
  134. reverseTag,
  135. reverseSniffing,
  136. testpre: asNumber(raw.testpre, 0),
  137. testseed,
  138. };
  139. }
  140. function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
  141. const s = asObject(asArray(raw.servers)[0]);
  142. return {
  143. address: asString(s.address),
  144. port: asPort(s.port, 443),
  145. password: asString(s.password),
  146. };
  147. }
  148. function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
  149. const s = asObject(asArray(raw.servers)[0]);
  150. return {
  151. address: asString(s.address),
  152. port: asPort(s.port, 443),
  153. password: asString(s.password),
  154. method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
  155. uot: asBool(s.uot),
  156. UoTVersion: asNumber(s.UoTVersion, 1),
  157. };
  158. }
  159. interface SimpleAuthFormSettings {
  160. address: string;
  161. port: number;
  162. user: string;
  163. pass: string;
  164. }
  165. function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
  166. const s = asObject(asArray(raw.servers)[0]);
  167. const u = asObject(asArray(s.users)[0]);
  168. return {
  169. address: asString(s.address),
  170. port: asPort(s.port, defaultPort),
  171. user: asString(u.user),
  172. pass: asString(u.pass),
  173. };
  174. }
  175. function stringRecordFromWire(raw: unknown): Record<string, string> {
  176. const obj = asObject(raw);
  177. const out: Record<string, string> = {};
  178. for (const [k, v] of Object.entries(obj)) {
  179. if (typeof v === 'string') out[k] = v;
  180. }
  181. return out;
  182. }
  183. // HTTP outbound reuses the SOCKS server/user shape but also carries xray's
  184. // top-level `settings.headers` (HTTPClientConfig.Headers), the CONNECT
  185. // headers sent to the upstream proxy. xray ignores per-server `headers`,
  186. // so only the settings-level map round-trips (issue #5519).
  187. function httpFromWire(raw: Raw): HttpOutboundFormSettings {
  188. return {
  189. ...simpleAuthFromWire(raw, 8080),
  190. headers: stringRecordFromWire(raw.headers),
  191. };
  192. }
  193. function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
  194. const secretKey = asString(raw.secretKey);
  195. const pubKey = secretKey.length > 0
  196. ? Wireguard.generateKeypair(secretKey).publicKey
  197. : '';
  198. const addressArr = asArray(raw.address).map((x) =>
  199. typeof x === 'number' ? String(x) : asString(x),
  200. );
  201. const reservedArr = asArray(raw.reserved).map((x) =>
  202. typeof x === 'number' ? String(x) : asString(x),
  203. );
  204. const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
  205. const pp = asObject(p);
  206. const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
  207. return {
  208. publicKey: asString(pp.publicKey),
  209. psk: asString(pp.preSharedKey),
  210. allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
  211. endpoint: asString(pp.endpoint),
  212. keepAlive: asNumber(pp.keepAlive, 0),
  213. };
  214. });
  215. return {
  216. mtu: asNumber(raw.mtu, 1420),
  217. secretKey,
  218. pubKey,
  219. address: addressArr.join(','),
  220. domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
  221. const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
  222. const s = asString(raw.domainStrategy);
  223. return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
  224. })(),
  225. reserved: reservedArr.join(','),
  226. peers,
  227. noKernelTun: asBool(raw.noKernelTun),
  228. };
  229. }
  230. function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
  231. return {
  232. address: asString(raw.address),
  233. port: asPort(raw.port, 443),
  234. version: 2,
  235. };
  236. }
  237. function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
  238. const fragment = asObject(raw.fragment);
  239. const noises = asArray(raw.noises).map((n) => {
  240. const nn = asObject(n);
  241. return {
  242. type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
  243. packet: asString(nn.packet, '10-20'),
  244. delay: asString(nn.delay, '10-16'),
  245. applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
  246. };
  247. });
  248. const finalRulesRaw = asArray(raw.finalRules);
  249. const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
  250. const rr = asObject(r);
  251. const network = Array.isArray(rr.network)
  252. ? rr.network.map((x) => asString(x)).join(',')
  253. : asString(rr.network);
  254. return {
  255. action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
  256. network,
  257. port: asString(rr.port),
  258. ip: asArray(rr.ip).map((x) => asString(x)),
  259. blockDelay: asString(rr.blockDelay),
  260. };
  261. });
  262. // Legacy ipsBlocked → finalRule(block) backfill
  263. if (finalRules.length === 0) {
  264. const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
  265. if (ipsBlocked.length > 0) {
  266. finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
  267. }
  268. }
  269. // Wire fragment is either missing or a populated object. Mirror the
  270. // legacy behavior: when the wire omits fragment, leave all four fields
  271. // empty so the modal's "Fragment" Switch starts off. When present,
  272. // surface whatever the wire holds verbatim.
  273. const wireHasFragment = raw.fragment != null
  274. && typeof raw.fragment === 'object'
  275. && Object.keys(fragment).length > 0;
  276. return {
  277. domainStrategy: targetStrategyFromWire(
  278. asString(raw.targetStrategy) || asString(raw.domainStrategy),
  279. ),
  280. redirect: asString(raw.redirect),
  281. userLevel: asNumber(raw.userLevel, 0),
  282. proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
  283. const n = asNumber(raw.proxyProtocol, 0);
  284. return (n === 1 || n === 2) ? n : 0;
  285. })(),
  286. fragment: wireHasFragment
  287. ? {
  288. packets: asString(fragment.packets, '1-3'),
  289. length: asString(fragment.length),
  290. interval: asString(fragment.interval),
  291. maxSplit: asString(fragment.maxSplit),
  292. }
  293. : { packets: '', length: '', interval: '', maxSplit: '' },
  294. noises,
  295. finalRules,
  296. };
  297. }
  298. function blackholeFromWire(raw: Raw) {
  299. const response = asObject(raw.response);
  300. const t = asString(response.type);
  301. return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
  302. }
  303. function dnsRuleFromWire(raw: unknown): DnsRuleForm {
  304. const r = asObject(raw);
  305. const rawQType = r.qType ?? r.qtype;
  306. const qType = Array.isArray(rawQType)
  307. ? rawQType.map((x) => String(x)).join(',')
  308. : typeof rawQType === 'number'
  309. ? String(rawQType)
  310. : asString(rawQType);
  311. const domain = Array.isArray(r.domain)
  312. ? r.domain.map((x) => asString(x)).join(',')
  313. : asString(r.domain);
  314. const action = asString(r.action, 'direct');
  315. const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action)
  316. ? action
  317. : 'direct';
  318. return { action: validAction as DnsRuleForm['action'], qType, domain, rCode: asNumber(r.rCode, 0) };
  319. }
  320. function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
  321. const rules = asArray(raw.rules).map(dnsRuleFromWire);
  322. return {
  323. rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
  324. const s = asString(raw.rewriteNetwork ?? raw.network);
  325. return (s === 'udp' || s === 'tcp') ? s : '';
  326. })(),
  327. rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
  328. rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
  329. userLevel: asNumber(raw.userLevel, 0),
  330. rules,
  331. };
  332. }
  333. function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
  334. return {
  335. inboundTag: asString(raw.inboundTag),
  336. sniffing: sniffingFromWire(raw.sniffing),
  337. };
  338. }
  339. function muxFromWire(raw: unknown): MuxForm {
  340. const m = asObject(raw);
  341. return {
  342. enabled: asBool(m.enabled),
  343. concurrency: asNumber(m.concurrency, 8),
  344. xudpConcurrency: asNumber(m.xudpConcurrency, 16),
  345. xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
  346. const s = asString(m.xudpProxyUDP443, 'reject');
  347. return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
  348. })(),
  349. };
  350. }
  351. export interface RawOutboundRow {
  352. tag?: string;
  353. protocol?: string;
  354. sendThrough?: string;
  355. targetStrategy?: string;
  356. settings?: unknown;
  357. streamSettings?: unknown;
  358. mux?: unknown;
  359. }
  360. export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
  361. function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
  362. const next = { ...stream };
  363. const xh = next.xhttpSettings;
  364. if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
  365. const xhttp = { ...(xh as Raw) };
  366. const xmux = xhttp.xmux;
  367. if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
  368. xhttp.enableXmux = true;
  369. xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Raw) };
  370. }
  371. next.xhttpSettings = xhttp;
  372. }
  373. return next as unknown as OutboundStreamFormValues;
  374. }
  375. export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
  376. const protocol = asString(raw.protocol, 'vless');
  377. const settings = asObject(raw.settings);
  378. const tag = asString(raw.tag);
  379. const sendThrough = asString(raw.sendThrough);
  380. const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
  381. const mux = muxFromWire(raw.mux);
  382. const hasStream = raw.streamSettings
  383. && typeof raw.streamSettings === 'object'
  384. && Object.keys(raw.streamSettings as Raw).length > 0;
  385. const streamSettings = hasStream
  386. ? hydrateStreamForm(raw.streamSettings as Raw)
  387. : undefined;
  388. let typed: OutboundFormSettings;
  389. switch (protocol) {
  390. case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
  391. case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
  392. case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
  393. case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
  394. case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
  395. case 'http': typed = { protocol: 'http', settings: httpFromWire(settings) }; break;
  396. case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
  397. case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
  398. case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
  399. case 'blackhole': typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) }; break;
  400. case 'dns': typed = { protocol: 'dns', settings: dnsFromWire(settings) }; break;
  401. case 'loopback': typed = { protocol: 'loopback', settings: loopbackFromWire(settings) }; break;
  402. default: typed = { protocol: 'vless', settings: vlessFromWire(settings) };
  403. }
  404. return {
  405. ...typed,
  406. tag,
  407. sendThrough,
  408. targetStrategy,
  409. mux,
  410. streamSettings,
  411. };
  412. }
  413. // --- Form values -> wire payload --------------------------------------
  414. function vmessToWire(s: VmessOutboundFormSettings) {
  415. return {
  416. vnext: [{
  417. address: s.address,
  418. port: s.port,
  419. users: [{ id: s.id, security: s.security }],
  420. }],
  421. };
  422. }
  423. function sniffingToWire(s: Sniffing) {
  424. return {
  425. enabled: s.enabled,
  426. destOverride: s.destOverride,
  427. metadataOnly: s.metadataOnly,
  428. routeOnly: s.routeOnly,
  429. ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
  430. domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
  431. };
  432. }
  433. function vlessToWire(s: VlessOutboundFormSettings) {
  434. const result: Raw = {
  435. address: s.address,
  436. port: s.port,
  437. id: s.id,
  438. flow: s.flow,
  439. encryption: s.encryption || 'none',
  440. };
  441. if (s.reverseTag) {
  442. const sn = sniffingToWire(s.reverseSniffing);
  443. const defaultSn = sniffingToWire(SNIFFING_DEFAULT);
  444. result.reverse = {
  445. tag: s.reverseTag,
  446. sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
  447. };
  448. }
  449. if (s.flow === 'xtls-rprx-vision') {
  450. if (s.testpre > 0) result.testpre = s.testpre;
  451. if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
  452. result.testseed = s.testseed;
  453. }
  454. }
  455. return result;
  456. }
  457. function trojanToWire(s: TrojanOutboundFormSettings) {
  458. return { servers: [{ address: s.address, port: s.port, password: s.password }] };
  459. }
  460. function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
  461. return {
  462. servers: [{
  463. address: s.address,
  464. port: s.port,
  465. password: s.password,
  466. method: s.method,
  467. uot: s.uot,
  468. UoTVersion: s.UoTVersion,
  469. }],
  470. };
  471. }
  472. function simpleAuthToWire(s: SimpleAuthFormSettings) {
  473. return {
  474. servers: [{
  475. address: s.address,
  476. port: s.port,
  477. users: s.user ? [{ user: s.user, pass: s.pass }] : [],
  478. }],
  479. };
  480. }
  481. function httpToWire(s: HttpOutboundFormSettings): Raw {
  482. const wire: Raw = simpleAuthToWire(s);
  483. if (s.headers && Object.keys(s.headers).length > 0) {
  484. wire.headers = s.headers;
  485. }
  486. return wire;
  487. }
  488. function wireguardToWire(s: WireguardOutboundFormSettings) {
  489. return {
  490. mtu: s.mtu || undefined,
  491. secretKey: s.secretKey,
  492. address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
  493. domainStrategy: s.domainStrategy || undefined,
  494. reserved: s.reserved
  495. ? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
  496. : undefined,
  497. peers: s.peers.map((p) => ({
  498. publicKey: p.publicKey,
  499. preSharedKey: p.psk.length > 0 ? p.psk : undefined,
  500. allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
  501. endpoint: p.endpoint,
  502. keepAlive: p.keepAlive || undefined,
  503. })),
  504. noKernelTun: s.noKernelTun,
  505. };
  506. }
  507. function hysteriaToWire(s: HysteriaOutboundFormSettings) {
  508. return { address: s.address, port: s.port, version: s.version };
  509. }
  510. function freedomToWire(s: FreedomOutboundFormSettings) {
  511. // The strategy is emitted under the legacy domainStrategy key: new cores
  512. // fall back to it when targetStrategy is absent, old cores only know it.
  513. // Legacy semantics: emit fragment only when the user actually populated
  514. // at least one of the four sub-fields. Defaults like packets='1-3' alone
  515. // are not enough — the modal's Fragment Switch sets all four together.
  516. // getFieldsValue(true) may omit `fragment` when the switch is off, so the
  517. // fallback keeps Object.entries from throwing on undefined (issue #4686).
  518. const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
  519. const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
  520. const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
  521. return {
  522. domainStrategy: s.domainStrategy || undefined,
  523. redirect: s.redirect || undefined,
  524. userLevel: s.userLevel || undefined,
  525. proxyProtocol: s.proxyProtocol || undefined,
  526. fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
  527. noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
  528. finalRules: s.finalRules && s.finalRules.length > 0
  529. ? s.finalRules.map((r) => ({
  530. action: r.action,
  531. network: r.network || undefined,
  532. port: r.port || undefined,
  533. ip: r.ip.length > 0 ? r.ip : undefined,
  534. blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
  535. }))
  536. : undefined,
  537. };
  538. }
  539. function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
  540. return { response: s.type ? { type: s.type } : undefined };
  541. }
  542. function dnsRuleToWire(r: DnsRuleForm) {
  543. const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action)
  544. ? r.action
  545. : 'direct';
  546. const result: Raw = { action };
  547. const qType = r.qType.trim();
  548. if (qType) {
  549. result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
  550. }
  551. const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
  552. if (domains.length > 0) result.domain = domains;
  553. if (r.rCode > 0) result.rCode = r.rCode;
  554. return result;
  555. }
  556. function dnsToWire(s: DnsOutboundFormSettings) {
  557. const result: Raw = {};
  558. if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
  559. if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
  560. if (s.rewritePort) result.rewritePort = s.rewritePort;
  561. if (s.userLevel) result.userLevel = s.userLevel;
  562. if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
  563. return result;
  564. }
  565. function loopbackToWire(s: LoopbackOutboundFormSettings) {
  566. const result: Raw = { inboundTag: s.inboundTag || undefined };
  567. // Sniffing rides only when enabled — a disabled block is a no-op for
  568. // xray's BuildSniffingRequest, so omitting it keeps the wire minimal.
  569. if (s.sniffing.enabled) {
  570. result.sniffing = sniffingToWire(s.sniffing);
  571. }
  572. return result;
  573. }
  574. // canEnableMux mirrors the legacy Outbound.canEnableMux().
  575. const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
  576. const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
  577. function dropEmptyStrings(obj: Raw): Raw {
  578. const out: Raw = {};
  579. for (const [k, v] of Object.entries(obj)) {
  580. if (v === '') continue;
  581. out[k] = v;
  582. }
  583. return out;
  584. }
  585. function stripUiOnlyStreamFields(stream: unknown): Raw {
  586. const next = { ...(stream as Raw) };
  587. const xh = next.xhttpSettings;
  588. if (xh && typeof xh === 'object') {
  589. const cleaned = { ...(xh as Raw) };
  590. const xmuxEnabled = cleaned.enableXmux === true;
  591. delete cleaned.enableXmux;
  592. if (!xmuxEnabled) delete cleaned.xmux;
  593. next.xhttpSettings = dropEmptyStrings(cleaned);
  594. }
  595. return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
  596. }
  597. function muxAllowed(values: OutboundFormValues): boolean {
  598. if (!MUX_PROTOCOLS.has(values.protocol)) return false;
  599. const flow = values.protocol === 'vless'
  600. ? (values.settings as VlessOutboundFormSettings).flow
  601. : '';
  602. if (flow) return false;
  603. const network = values.streamSettings && 'network' in values.streamSettings
  604. ? values.streamSettings.network
  605. : undefined;
  606. if (network === 'xhttp') return false;
  607. return true;
  608. }
  609. export type WireOutboundPayload = Raw;
  610. export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
  611. let settings: Raw;
  612. switch (values.protocol) {
  613. case 'vmess': settings = vmessToWire(values.settings); break;
  614. case 'vless': settings = vlessToWire(values.settings); break;
  615. case 'trojan': settings = trojanToWire(values.settings); break;
  616. case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
  617. case 'socks': settings = simpleAuthToWire(values.settings); break;
  618. case 'http': settings = httpToWire(values.settings); break;
  619. case 'wireguard': settings = wireguardToWire(values.settings); break;
  620. case 'hysteria': settings = hysteriaToWire(values.settings); break;
  621. case 'freedom': settings = freedomToWire(values.settings); break;
  622. case 'blackhole': settings = blackholeToWire(values.settings); break;
  623. case 'dns': settings = dnsToWire(values.settings); break;
  624. case 'loopback': settings = loopbackToWire(values.settings); break;
  625. }
  626. const result: Raw = {
  627. protocol: values.protocol,
  628. settings,
  629. };
  630. if (values.tag) result.tag = values.tag;
  631. if (values.targetStrategy) result.targetStrategy = values.targetStrategy;
  632. // streamSettings emission gates on canEnableStream — non-stream protocols
  633. // still emit just `sockopt` if that key is present (legacy behavior).
  634. if (values.streamSettings) {
  635. if (STREAM_PROTOCOLS.has(values.protocol)) {
  636. result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
  637. } else {
  638. const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
  639. if (sockopt) result.streamSettings = { sockopt };
  640. }
  641. }
  642. if (values.sendThrough) result.sendThrough = values.sendThrough;
  643. // mux may be absent when the modal didn't render the Mux switch (non-
  644. // stream protocols or when isMuxAllowed gated it out). validateFields()
  645. // only returns registered fields, so values.mux can be undefined.
  646. if (values.mux?.enabled && muxAllowed(values)) {
  647. result.mux = values.mux;
  648. }
  649. return result;
  650. }