outbound-form-adapter.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import { Wireguard } from '@/utils';
  2. import type {
  3. DnsOutboundFormSettings,
  4. DnsRuleForm,
  5. FreedomFinalRuleForm,
  6. FreedomOutboundFormSettings,
  7. HysteriaOutboundFormSettings,
  8. LoopbackOutboundFormSettings,
  9. MuxForm,
  10. OutboundFormSettings,
  11. OutboundFormValues,
  12. OutboundStreamFormValues,
  13. ReverseSniffingForm,
  14. ShadowsocksOutboundFormSettings,
  15. TrojanOutboundFormSettings,
  16. VlessOutboundFormSettings,
  17. VmessOutboundFormSettings,
  18. WireguardOutboundFormPeer,
  19. WireguardOutboundFormSettings,
  20. } from '@/schemas/forms/outbound-form';
  21. // Adapter between the wire-shape outbound JSON the panel stores in
  22. // templateSettings.outbounds[] and the typed OutboundFormValues the modal
  23. // holds in Form.useForm<T>. No dependency on the legacy Outbound class
  24. // hierarchy — the modal hands a wire-shape object in, takes typed values
  25. // out, and on submit calls formValuesToWirePayload() to get a plain JS
  26. // object ready to pass to onConfirm().
  27. type Raw = Record<string, unknown>;
  28. function asObject(value: unknown): Raw {
  29. return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
  30. }
  31. function asArray(value: unknown): unknown[] {
  32. return Array.isArray(value) ? value : [];
  33. }
  34. function asString(value: unknown, fallback = ''): string {
  35. return typeof value === 'string' ? value : fallback;
  36. }
  37. function asNumber(value: unknown, fallback = 0): number {
  38. if (typeof value === 'number' && Number.isFinite(value)) return value;
  39. if (typeof value === 'string' && value.trim() !== '') {
  40. const n = Number(value);
  41. return Number.isFinite(n) ? n : fallback;
  42. }
  43. return fallback;
  44. }
  45. function asBool(value: unknown): boolean {
  46. return value === true;
  47. }
  48. function asPort(value: unknown, fallback: number): number {
  49. const n = asNumber(value, fallback);
  50. if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
  51. return n;
  52. }
  53. const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
  54. enabled: false,
  55. destOverride: ['http', 'tls', 'quic', 'fakedns'],
  56. metadataOnly: false,
  57. routeOnly: false,
  58. ipsExcluded: [],
  59. domainsExcluded: [],
  60. };
  61. function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
  62. const r = asObject(raw);
  63. const dest = asArray(r.destOverride).map((x) => asString(x));
  64. return {
  65. enabled: asBool(r.enabled),
  66. destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
  67. metadataOnly: asBool(r.metadataOnly),
  68. routeOnly: asBool(r.routeOnly),
  69. ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
  70. domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
  71. };
  72. }
  73. function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
  74. const vnext = asArray(raw.vnext);
  75. const v = asObject(vnext[0]);
  76. const u = asObject(asArray(v.users)[0]);
  77. return {
  78. address: asString(v.address),
  79. port: asPort(v.port, 443),
  80. id: asString(u.id),
  81. security: ((): VmessOutboundFormSettings['security'] => {
  82. const s = asString(u.security);
  83. const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
  84. return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
  85. })(),
  86. };
  87. }
  88. function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
  89. let address = asString(raw.address);
  90. let port = asPort(raw.port, 443);
  91. let id = asString(raw.id);
  92. let flow = asString(raw.flow);
  93. let encryption = asString(raw.encryption, 'none');
  94. const vnext = asArray(raw.vnext);
  95. if (vnext.length > 0) {
  96. const v = asObject(vnext[0]);
  97. const u = asObject(asArray(v.users)[0]);
  98. address = asString(v.address);
  99. port = asPort(v.port, 443);
  100. id = asString(u.id);
  101. flow = asString(u.flow);
  102. encryption = asString(u.encryption, 'none');
  103. }
  104. const reverse = asObject(raw.reverse);
  105. const reverseTag = asString(reverse.tag);
  106. const reverseSniffing = reverseTag
  107. ? reverseSniffingFromWire(reverse.sniffing)
  108. : REVERSE_SNIFFING_DEFAULT;
  109. const savedSeed = asArray(raw.testseed);
  110. const testseed = savedSeed.length === 4
  111. && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
  112. ? (savedSeed as number[])
  113. : [];
  114. return {
  115. address,
  116. port,
  117. id,
  118. flow,
  119. encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
  120. reverseTag,
  121. reverseSniffing,
  122. testpre: asNumber(raw.testpre, 0),
  123. testseed,
  124. };
  125. }
  126. function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
  127. const s = asObject(asArray(raw.servers)[0]);
  128. return {
  129. address: asString(s.address),
  130. port: asPort(s.port, 443),
  131. password: asString(s.password),
  132. };
  133. }
  134. function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
  135. const s = asObject(asArray(raw.servers)[0]);
  136. return {
  137. address: asString(s.address),
  138. port: asPort(s.port, 443),
  139. password: asString(s.password),
  140. method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
  141. uot: asBool(s.uot),
  142. UoTVersion: asNumber(s.UoTVersion, 1),
  143. };
  144. }
  145. interface SimpleAuthFormSettings {
  146. address: string;
  147. port: number;
  148. user: string;
  149. pass: string;
  150. }
  151. function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
  152. const s = asObject(asArray(raw.servers)[0]);
  153. const u = asObject(asArray(s.users)[0]);
  154. return {
  155. address: asString(s.address),
  156. port: asPort(s.port, defaultPort),
  157. user: asString(u.user),
  158. pass: asString(u.pass),
  159. };
  160. }
  161. function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
  162. const secretKey = asString(raw.secretKey);
  163. const pubKey = secretKey.length > 0
  164. ? Wireguard.generateKeypair(secretKey).publicKey
  165. : '';
  166. const addressArr = asArray(raw.address).map((x) =>
  167. typeof x === 'number' ? String(x) : asString(x),
  168. );
  169. const reservedArr = asArray(raw.reserved).map((x) =>
  170. typeof x === 'number' ? String(x) : asString(x),
  171. );
  172. const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
  173. const pp = asObject(p);
  174. const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
  175. return {
  176. publicKey: asString(pp.publicKey),
  177. psk: asString(pp.preSharedKey),
  178. allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
  179. endpoint: asString(pp.endpoint),
  180. keepAlive: asNumber(pp.keepAlive, 0),
  181. };
  182. });
  183. return {
  184. mtu: asNumber(raw.mtu, 1420),
  185. secretKey,
  186. pubKey,
  187. address: addressArr.join(','),
  188. workers: asNumber(raw.workers, 2),
  189. domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
  190. const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
  191. const s = asString(raw.domainStrategy);
  192. return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
  193. })(),
  194. reserved: reservedArr.join(','),
  195. peers,
  196. noKernelTun: asBool(raw.noKernelTun),
  197. };
  198. }
  199. function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
  200. return {
  201. address: asString(raw.address),
  202. port: asPort(raw.port, 443),
  203. version: 2,
  204. };
  205. }
  206. function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
  207. const fragment = asObject(raw.fragment);
  208. const noises = asArray(raw.noises).map((n) => {
  209. const nn = asObject(n);
  210. return {
  211. type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
  212. packet: asString(nn.packet, '10-20'),
  213. delay: asString(nn.delay, '10-16'),
  214. applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
  215. };
  216. });
  217. const finalRulesRaw = asArray(raw.finalRules);
  218. const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
  219. const rr = asObject(r);
  220. const network = Array.isArray(rr.network)
  221. ? rr.network.map((x) => asString(x)).join(',')
  222. : asString(rr.network);
  223. return {
  224. action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
  225. network,
  226. port: asString(rr.port),
  227. ip: asArray(rr.ip).map((x) => asString(x)),
  228. blockDelay: asString(rr.blockDelay),
  229. };
  230. });
  231. // Legacy ipsBlocked → finalRule(block) backfill
  232. if (finalRules.length === 0) {
  233. const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
  234. if (ipsBlocked.length > 0) {
  235. finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
  236. }
  237. }
  238. // Wire fragment is either missing or a populated object. Mirror the
  239. // legacy behavior: when the wire omits fragment, leave all four fields
  240. // empty so the modal's "Fragment" Switch starts off. When present,
  241. // surface whatever the wire holds verbatim.
  242. const wireHasFragment = raw.fragment != null
  243. && typeof raw.fragment === 'object'
  244. && Object.keys(fragment).length > 0;
  245. return {
  246. domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
  247. const allowed = [
  248. 'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
  249. 'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
  250. ];
  251. const s = asString(raw.domainStrategy);
  252. return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
  253. })(),
  254. redirect: asString(raw.redirect),
  255. fragment: wireHasFragment
  256. ? {
  257. packets: asString(fragment.packets, '1-3'),
  258. length: asString(fragment.length),
  259. interval: asString(fragment.interval),
  260. maxSplit: asString(fragment.maxSplit),
  261. }
  262. : { packets: '', length: '', interval: '', maxSplit: '' },
  263. noises,
  264. finalRules,
  265. };
  266. }
  267. function blackholeFromWire(raw: Raw) {
  268. const response = asObject(raw.response);
  269. const t = asString(response.type);
  270. return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
  271. }
  272. function dnsRuleFromWire(raw: unknown): DnsRuleForm {
  273. const r = asObject(raw);
  274. const qtype = Array.isArray(r.qtype)
  275. ? r.qtype.map((x) => String(x)).join(',')
  276. : typeof r.qtype === 'number'
  277. ? String(r.qtype)
  278. : asString(r.qtype);
  279. const domain = Array.isArray(r.domain)
  280. ? r.domain.map((x) => asString(x)).join(',')
  281. : asString(r.domain);
  282. const action = asString(r.action, 'direct');
  283. const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action)
  284. ? action
  285. : 'direct';
  286. return { action: validAction as DnsRuleForm['action'], qtype, domain };
  287. }
  288. function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
  289. const rules = asArray(raw.rules).map(dnsRuleFromWire);
  290. return {
  291. rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
  292. const s = asString(raw.rewriteNetwork ?? raw.network);
  293. return (s === 'udp' || s === 'tcp') ? s : '';
  294. })(),
  295. rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
  296. rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
  297. userLevel: asNumber(raw.userLevel, 0),
  298. rules,
  299. };
  300. }
  301. function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
  302. return { inboundTag: asString(raw.inboundTag) };
  303. }
  304. function muxFromWire(raw: unknown): MuxForm {
  305. const m = asObject(raw);
  306. return {
  307. enabled: asBool(m.enabled),
  308. concurrency: asNumber(m.concurrency, 8),
  309. xudpConcurrency: asNumber(m.xudpConcurrency, 16),
  310. xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
  311. const s = asString(m.xudpProxyUDP443, 'reject');
  312. return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
  313. })(),
  314. };
  315. }
  316. export interface RawOutboundRow {
  317. tag?: string;
  318. protocol?: string;
  319. sendThrough?: string;
  320. settings?: unknown;
  321. streamSettings?: unknown;
  322. mux?: unknown;
  323. }
  324. // Convert wire-shape outbound (the object stored in
  325. // templateSettings.outbounds[]) into typed form values. Stream + mux are
  326. // minimal placeholders for now — the modal will fold the real stream sub-
  327. // form in when those sections come online.
  328. export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
  329. const protocol = asString(raw.protocol, 'vless');
  330. const settings = asObject(raw.settings);
  331. const tag = asString(raw.tag);
  332. const sendThrough = asString(raw.sendThrough);
  333. const mux = muxFromWire(raw.mux);
  334. // Leave streamSettings undefined when missing or empty — the modal's
  335. // stream tab seeds it when the user opens the relevant section. This
  336. // keeps Form.useForm from receiving a value that doesn't match the
  337. // NetworkSettings DU.
  338. const hasStream = raw.streamSettings
  339. && typeof raw.streamSettings === 'object'
  340. && Object.keys(raw.streamSettings as Raw).length > 0;
  341. const streamSettings = hasStream
  342. ? (raw.streamSettings as unknown as OutboundStreamFormValues)
  343. : undefined;
  344. let typed: OutboundFormSettings;
  345. switch (protocol) {
  346. case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
  347. case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
  348. case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
  349. case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
  350. case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
  351. case 'http': typed = { protocol: 'http', settings: simpleAuthFromWire(settings, 8080) }; break;
  352. case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
  353. case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
  354. case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
  355. case 'blackhole': typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) }; break;
  356. case 'dns': typed = { protocol: 'dns', settings: dnsFromWire(settings) }; break;
  357. case 'loopback': typed = { protocol: 'loopback', settings: loopbackFromWire(settings) }; break;
  358. default: typed = { protocol: 'vless', settings: vlessFromWire(settings) };
  359. }
  360. return {
  361. ...typed,
  362. tag,
  363. sendThrough,
  364. mux,
  365. streamSettings,
  366. };
  367. }
  368. // --- Form values -> wire payload --------------------------------------
  369. function vmessToWire(s: VmessOutboundFormSettings) {
  370. return {
  371. vnext: [{
  372. address: s.address,
  373. port: s.port,
  374. users: [{ id: s.id, security: s.security }],
  375. }],
  376. };
  377. }
  378. function reverseSniffingToWire(s: ReverseSniffingForm) {
  379. return {
  380. enabled: s.enabled,
  381. destOverride: s.destOverride,
  382. metadataOnly: s.metadataOnly,
  383. routeOnly: s.routeOnly,
  384. ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
  385. domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
  386. };
  387. }
  388. function vlessToWire(s: VlessOutboundFormSettings) {
  389. const result: Raw = {
  390. address: s.address,
  391. port: s.port,
  392. id: s.id,
  393. flow: s.flow,
  394. encryption: s.encryption || 'none',
  395. };
  396. if (s.reverseTag) {
  397. const sn = reverseSniffingToWire(s.reverseSniffing);
  398. const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
  399. result.reverse = {
  400. tag: s.reverseTag,
  401. sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
  402. };
  403. }
  404. if (s.flow === 'xtls-rprx-vision') {
  405. if (s.testpre > 0) result.testpre = s.testpre;
  406. if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
  407. result.testseed = s.testseed;
  408. }
  409. }
  410. return result;
  411. }
  412. function trojanToWire(s: TrojanOutboundFormSettings) {
  413. return { servers: [{ address: s.address, port: s.port, password: s.password }] };
  414. }
  415. function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
  416. return {
  417. servers: [{
  418. address: s.address,
  419. port: s.port,
  420. password: s.password,
  421. method: s.method,
  422. uot: s.uot,
  423. UoTVersion: s.UoTVersion,
  424. }],
  425. };
  426. }
  427. function simpleAuthToWire(s: SimpleAuthFormSettings) {
  428. return {
  429. servers: [{
  430. address: s.address,
  431. port: s.port,
  432. users: s.user ? [{ user: s.user, pass: s.pass }] : [],
  433. }],
  434. };
  435. }
  436. function wireguardToWire(s: WireguardOutboundFormSettings) {
  437. return {
  438. mtu: s.mtu || undefined,
  439. secretKey: s.secretKey,
  440. address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
  441. workers: s.workers || undefined,
  442. domainStrategy: s.domainStrategy || undefined,
  443. reserved: s.reserved
  444. ? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
  445. : undefined,
  446. peers: s.peers.map((p) => ({
  447. publicKey: p.publicKey,
  448. preSharedKey: p.psk.length > 0 ? p.psk : undefined,
  449. allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
  450. endpoint: p.endpoint,
  451. keepAlive: p.keepAlive || undefined,
  452. })),
  453. noKernelTun: s.noKernelTun,
  454. };
  455. }
  456. function hysteriaToWire(s: HysteriaOutboundFormSettings) {
  457. return { address: s.address, port: s.port, version: s.version };
  458. }
  459. function freedomToWire(s: FreedomOutboundFormSettings) {
  460. // Legacy semantics: emit fragment only when the user actually populated
  461. // at least one of the four sub-fields. Defaults like packets='1-3' alone
  462. // are not enough — the modal's Fragment Switch sets all four together.
  463. const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
  464. const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
  465. return {
  466. domainStrategy: s.domainStrategy || undefined,
  467. redirect: s.redirect || undefined,
  468. fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
  469. noises: s.noises.length > 0 ? s.noises : undefined,
  470. finalRules: s.finalRules.length > 0
  471. ? s.finalRules.map((r) => ({
  472. action: r.action,
  473. network: r.network || undefined,
  474. port: r.port || undefined,
  475. ip: r.ip.length > 0 ? r.ip : undefined,
  476. blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
  477. }))
  478. : undefined,
  479. };
  480. }
  481. function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
  482. return { response: s.type ? { type: s.type } : undefined };
  483. }
  484. function dnsRuleToWire(r: DnsRuleForm) {
  485. const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action)
  486. ? r.action
  487. : 'direct';
  488. const result: Raw = { action };
  489. const qtype = r.qtype.trim();
  490. if (qtype) {
  491. result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype;
  492. }
  493. const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
  494. if (domains.length > 0) result.domain = domains;
  495. return result;
  496. }
  497. function dnsToWire(s: DnsOutboundFormSettings) {
  498. const result: Raw = {};
  499. if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
  500. if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
  501. if (s.rewritePort) result.rewritePort = s.rewritePort;
  502. if (s.userLevel) result.userLevel = s.userLevel;
  503. if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
  504. return result;
  505. }
  506. function loopbackToWire(s: LoopbackOutboundFormSettings) {
  507. return { inboundTag: s.inboundTag || undefined };
  508. }
  509. // canEnableMux mirrors the legacy Outbound.canEnableMux().
  510. const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
  511. const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
  512. // Strip UI-only fields the form layered into streamSettings (e.g. the
  513. // XHTTP modal's enableXmux toggle that controls section visibility but
  514. // has no meaning on the wire). xray-core would ignore unknown fields
  515. // anyway but the panel reads back its own emitted JSON, so we keep
  516. // the wire shape clean.
  517. function stripUiOnlyStreamFields(stream: unknown): Raw {
  518. const next = { ...(stream as Raw) };
  519. const xh = next.xhttpSettings;
  520. if (xh && typeof xh === 'object') {
  521. const cleaned = { ...(xh as Raw) };
  522. delete cleaned.enableXmux;
  523. next.xhttpSettings = cleaned;
  524. }
  525. return next;
  526. }
  527. function muxAllowed(values: OutboundFormValues): boolean {
  528. if (!MUX_PROTOCOLS.has(values.protocol)) return false;
  529. const flow = values.protocol === 'vless'
  530. ? (values.settings as VlessOutboundFormSettings).flow
  531. : '';
  532. if (flow) return false;
  533. const network = values.streamSettings && 'network' in values.streamSettings
  534. ? values.streamSettings.network
  535. : undefined;
  536. if (network === 'xhttp') return false;
  537. return true;
  538. }
  539. export type WireOutboundPayload = Raw;
  540. export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
  541. let settings: Raw;
  542. switch (values.protocol) {
  543. case 'vmess': settings = vmessToWire(values.settings); break;
  544. case 'vless': settings = vlessToWire(values.settings); break;
  545. case 'trojan': settings = trojanToWire(values.settings); break;
  546. case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
  547. case 'socks': settings = simpleAuthToWire(values.settings); break;
  548. case 'http': settings = simpleAuthToWire(values.settings); break;
  549. case 'wireguard': settings = wireguardToWire(values.settings); break;
  550. case 'hysteria': settings = hysteriaToWire(values.settings); break;
  551. case 'freedom': settings = freedomToWire(values.settings); break;
  552. case 'blackhole': settings = blackholeToWire(values.settings); break;
  553. case 'dns': settings = dnsToWire(values.settings); break;
  554. case 'loopback': settings = loopbackToWire(values.settings); break;
  555. }
  556. const result: Raw = {
  557. protocol: values.protocol,
  558. settings,
  559. };
  560. if (values.tag) result.tag = values.tag;
  561. // streamSettings emission gates on canEnableStream — non-stream protocols
  562. // still emit just `sockopt` if that key is present (legacy behavior).
  563. if (values.streamSettings) {
  564. if (STREAM_PROTOCOLS.has(values.protocol)) {
  565. result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
  566. } else {
  567. const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
  568. if (sockopt) result.streamSettings = { sockopt };
  569. }
  570. }
  571. if (values.sendThrough) result.sendThrough = values.sendThrough;
  572. // mux may be absent when the modal didn't render the Mux switch (non-
  573. // stream protocols or when isMuxAllowed gated it out). validateFields()
  574. // only returns registered fields, so values.mux can be undefined.
  575. if (values.mux?.enabled && muxAllowed(values)) {
  576. result.mux = values.mux;
  577. }
  578. return result;
  579. }