Quellcode durchsuchen

feat(frontend): outbound form schema + wire adapter foundation

Lay the groundwork for OutboundFormModal's Pattern A rewrite:

- schemas/forms/outbound-form.ts: discriminated-union form values across
  all 12 outbound protocols, with flat per-protocol settings shapes that
  match the legacy class fields (vmess vnext / trojan-ss-socks-http
  servers / wireguard csv address-reserved all flattened).

- lib/xray/outbound-form-adapter.ts: rawOutboundToFormValues converts
  wire-shape outbound JSON to typed form values; formValuesToWirePayload
  re-nests on submit. Replaces the Outbound.fromJson/toJson dependency
  the modal currently has on the legacy class hierarchy.

- test/outbound-form-adapter.test.ts: 15 round-trip cases covering each
  protocol's wire quirks (vmess vnext flatten, vless reverse-wrap,
  wireguard csv↔array, blackhole response wrap, DNS rule normalization,
  mux gating).
MHSanaei vor 11 Stunden
Ursprung
Commit
b554bb6b75

+ 602 - 0
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -0,0 +1,602 @@
+import { Wireguard } from '@/utils';
+
+import type {
+  DnsOutboundFormSettings,
+  DnsRuleForm,
+  FreedomFinalRuleForm,
+  FreedomOutboundFormSettings,
+  HysteriaOutboundFormSettings,
+  LoopbackOutboundFormSettings,
+  MuxForm,
+  OutboundFormSettings,
+  OutboundFormValues,
+  OutboundStreamFormValues,
+  ReverseSniffingForm,
+  ShadowsocksOutboundFormSettings,
+  TrojanOutboundFormSettings,
+  VlessOutboundFormSettings,
+  VmessOutboundFormSettings,
+  WireguardOutboundFormPeer,
+  WireguardOutboundFormSettings,
+} from '@/schemas/forms/outbound-form';
+
+// Adapter between the wire-shape outbound JSON the panel stores in
+// templateSettings.outbounds[] and the typed OutboundFormValues the modal
+// holds in Form.useForm<T>. No dependency on the legacy Outbound class
+// hierarchy — the modal hands a wire-shape object in, takes typed values
+// out, and on submit calls formValuesToWirePayload() to get a plain JS
+// object ready to pass to onConfirm().
+
+type Raw = Record<string, unknown>;
+
+function asObject(value: unknown): Raw {
+  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
+}
+
+function asArray(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : [];
+}
+
+function asString(value: unknown, fallback = ''): string {
+  return typeof value === 'string' ? value : fallback;
+}
+
+function asNumber(value: unknown, fallback = 0): number {
+  if (typeof value === 'number' && Number.isFinite(value)) return value;
+  if (typeof value === 'string' && value.trim() !== '') {
+    const n = Number(value);
+    return Number.isFinite(n) ? n : fallback;
+  }
+  return fallback;
+}
+
+function asBool(value: unknown): boolean {
+  return value === true;
+}
+
+function asPort(value: unknown, fallback: number): number {
+  const n = asNumber(value, fallback);
+  if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
+  return n;
+}
+
+const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
+  enabled: false,
+  destOverride: ['http', 'tls', 'quic', 'fakedns'],
+  metadataOnly: false,
+  routeOnly: false,
+  ipsExcluded: [],
+  domainsExcluded: [],
+};
+
+function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
+  const r = asObject(raw);
+  const dest = asArray(r.destOverride).map((x) => asString(x));
+  return {
+    enabled: asBool(r.enabled),
+    destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
+    metadataOnly: asBool(r.metadataOnly),
+    routeOnly: asBool(r.routeOnly),
+    ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
+    domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
+  };
+}
+
+function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
+  const vnext = asArray(raw.vnext);
+  const v = asObject(vnext[0]);
+  const u = asObject(asArray(v.users)[0]);
+  return {
+    address: asString(v.address),
+    port: asPort(v.port, 443),
+    id: asString(u.id),
+    security: ((): VmessOutboundFormSettings['security'] => {
+      const s = asString(u.security);
+      const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
+      return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
+    })(),
+  };
+}
+
+function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
+  let address = asString(raw.address);
+  let port = asPort(raw.port, 443);
+  let id = asString(raw.id);
+  let flow = asString(raw.flow);
+  let encryption = asString(raw.encryption, 'none');
+  const vnext = asArray(raw.vnext);
+  if (vnext.length > 0) {
+    const v = asObject(vnext[0]);
+    const u = asObject(asArray(v.users)[0]);
+    address = asString(v.address);
+    port = asPort(v.port, 443);
+    id = asString(u.id);
+    flow = asString(u.flow);
+    encryption = asString(u.encryption, 'none');
+  }
+  const reverse = asObject(raw.reverse);
+  const reverseTag = asString(reverse.tag);
+  const reverseSniffing = reverseTag
+    ? reverseSniffingFromWire(reverse.sniffing)
+    : REVERSE_SNIFFING_DEFAULT;
+  const savedSeed = asArray(raw.testseed);
+  const testseed = savedSeed.length === 4
+    && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
+    ? (savedSeed as number[])
+    : [];
+  return {
+    address,
+    port,
+    id,
+    flow,
+    encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
+    reverseTag,
+    reverseSniffing,
+    testpre: asNumber(raw.testpre, 0),
+    testseed,
+  };
+}
+
+function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
+  const s = asObject(asArray(raw.servers)[0]);
+  return {
+    address: asString(s.address),
+    port: asPort(s.port, 443),
+    password: asString(s.password),
+  };
+}
+
+function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
+  const s = asObject(asArray(raw.servers)[0]);
+  return {
+    address: asString(s.address),
+    port: asPort(s.port, 443),
+    password: asString(s.password),
+    method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
+    uot: asBool(s.uot),
+    UoTVersion: asNumber(s.UoTVersion, 1),
+  };
+}
+
+interface SimpleAuthFormSettings {
+  address: string;
+  port: number;
+  user: string;
+  pass: string;
+}
+
+function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
+  const s = asObject(asArray(raw.servers)[0]);
+  const u = asObject(asArray(s.users)[0]);
+  return {
+    address: asString(s.address),
+    port: asPort(s.port, defaultPort),
+    user: asString(u.user),
+    pass: asString(u.pass),
+  };
+}
+
+function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
+  const secretKey = asString(raw.secretKey);
+  const pubKey = secretKey.length > 0
+    ? Wireguard.generateKeypair(secretKey).publicKey
+    : '';
+  const addressArr = asArray(raw.address).map((x) =>
+    typeof x === 'number' ? String(x) : asString(x),
+  );
+  const reservedArr = asArray(raw.reserved).map((x) =>
+    typeof x === 'number' ? String(x) : asString(x),
+  );
+  const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
+    const pp = asObject(p);
+    const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
+    return {
+      publicKey: asString(pp.publicKey),
+      psk: asString(pp.preSharedKey),
+      allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
+      endpoint: asString(pp.endpoint),
+      keepAlive: asNumber(pp.keepAlive, 0),
+    };
+  });
+  return {
+    mtu: asNumber(raw.mtu, 1420),
+    secretKey,
+    pubKey,
+    address: addressArr.join(','),
+    workers: asNumber(raw.workers, 2),
+    domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
+      const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
+      const s = asString(raw.domainStrategy);
+      return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
+    })(),
+    reserved: reservedArr.join(','),
+    peers,
+    noKernelTun: asBool(raw.noKernelTun),
+  };
+}
+
+function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
+  return {
+    address: asString(raw.address),
+    port: asPort(raw.port, 443),
+    version: 2,
+  };
+}
+
+function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
+  const fragment = asObject(raw.fragment);
+  const noises = asArray(raw.noises).map((n) => {
+    const nn = asObject(n);
+    return {
+      type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
+      packet: asString(nn.packet, '10-20'),
+      delay: asString(nn.delay, '10-16'),
+      applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
+    };
+  });
+  const finalRulesRaw = asArray(raw.finalRules);
+  const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
+    const rr = asObject(r);
+    const network = Array.isArray(rr.network)
+      ? rr.network.map((x) => asString(x)).join(',')
+      : asString(rr.network);
+    return {
+      action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
+      network,
+      port: asString(rr.port),
+      ip: asArray(rr.ip).map((x) => asString(x)),
+      blockDelay: asString(rr.blockDelay),
+    };
+  });
+  // Legacy ipsBlocked → finalRule(block) backfill
+  if (finalRules.length === 0) {
+    const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
+    if (ipsBlocked.length > 0) {
+      finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
+    }
+  }
+  // Wire fragment is either missing or a populated object. Mirror the
+  // legacy behavior: when the wire omits fragment, leave all four fields
+  // empty so the modal's "Fragment" Switch starts off. When present,
+  // surface whatever the wire holds verbatim.
+  const wireHasFragment = raw.fragment != null
+    && typeof raw.fragment === 'object'
+    && Object.keys(fragment).length > 0;
+  return {
+    domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
+      const allowed = [
+        'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
+        'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
+      ];
+      const s = asString(raw.domainStrategy);
+      return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
+    })(),
+    redirect: asString(raw.redirect),
+    fragment: wireHasFragment
+      ? {
+          packets: asString(fragment.packets, '1-3'),
+          length: asString(fragment.length),
+          interval: asString(fragment.interval),
+          maxSplit: asString(fragment.maxSplit),
+        }
+      : { packets: '', length: '', interval: '', maxSplit: '' },
+    noises,
+    finalRules,
+  };
+}
+
+function blackholeFromWire(raw: Raw) {
+  const response = asObject(raw.response);
+  const t = asString(response.type);
+  return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
+}
+
+function dnsRuleFromWire(raw: unknown): DnsRuleForm {
+  const r = asObject(raw);
+  const qtype = Array.isArray(r.qtype)
+    ? r.qtype.map((x) => String(x)).join(',')
+    : typeof r.qtype === 'number'
+      ? String(r.qtype)
+      : asString(r.qtype);
+  const domain = Array.isArray(r.domain)
+    ? r.domain.map((x) => asString(x)).join(',')
+    : asString(r.domain);
+  const action = asString(r.action, 'direct');
+  const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action)
+    ? action
+    : 'direct';
+  return { action: validAction as DnsRuleForm['action'], qtype, domain };
+}
+
+function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
+  const rules = asArray(raw.rules).map(dnsRuleFromWire);
+  return {
+    rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
+      const s = asString(raw.rewriteNetwork ?? raw.network);
+      return (s === 'udp' || s === 'tcp') ? s : '';
+    })(),
+    rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
+    rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
+    userLevel: asNumber(raw.userLevel, 0),
+    rules,
+  };
+}
+
+function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
+  return { inboundTag: asString(raw.inboundTag) };
+}
+
+function muxFromWire(raw: unknown): MuxForm {
+  const m = asObject(raw);
+  return {
+    enabled: asBool(m.enabled),
+    concurrency: asNumber(m.concurrency, 8),
+    xudpConcurrency: asNumber(m.xudpConcurrency, 16),
+    xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
+      const s = asString(m.xudpProxyUDP443, 'reject');
+      return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
+    })(),
+  };
+}
+
+export interface RawOutboundRow {
+  tag?: string;
+  protocol?: string;
+  sendThrough?: string;
+  settings?: unknown;
+  streamSettings?: unknown;
+  mux?: unknown;
+}
+
+// Convert wire-shape outbound (the object stored in
+// templateSettings.outbounds[]) into typed form values. Stream + mux are
+// minimal placeholders for now — the modal will fold the real stream sub-
+// form in when those sections come online.
+export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
+  const protocol = asString(raw.protocol, 'vless');
+  const settings = asObject(raw.settings);
+  const tag = asString(raw.tag);
+  const sendThrough = asString(raw.sendThrough);
+  const mux = muxFromWire(raw.mux);
+  const streamSettings = asObject(raw.streamSettings) as unknown as OutboundStreamFormValues | undefined;
+
+  let typed: OutboundFormSettings;
+  switch (protocol) {
+    case 'vmess':       typed = { protocol: 'vmess',       settings: vmessFromWire(settings) }; break;
+    case 'vless':       typed = { protocol: 'vless',       settings: vlessFromWire(settings) }; break;
+    case 'trojan':      typed = { protocol: 'trojan',      settings: trojanFromWire(settings) }; break;
+    case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
+    case 'socks':       typed = { protocol: 'socks',       settings: simpleAuthFromWire(settings, 1080) }; break;
+    case 'http':        typed = { protocol: 'http',        settings: simpleAuthFromWire(settings, 8080) }; break;
+    case 'wireguard':   typed = { protocol: 'wireguard',   settings: wireguardFromWire(settings) }; break;
+    case 'hysteria':    typed = { protocol: 'hysteria',    settings: hysteriaFromWire(settings) }; break;
+    case 'freedom':     typed = { protocol: 'freedom',     settings: freedomFromWire(settings) }; break;
+    case 'blackhole':   typed = { protocol: 'blackhole',   settings: blackholeFromWire(settings) }; break;
+    case 'dns':         typed = { protocol: 'dns',         settings: dnsFromWire(settings) }; break;
+    case 'loopback':    typed = { protocol: 'loopback',    settings: loopbackFromWire(settings) }; break;
+    default:            typed = { protocol: 'vless',       settings: vlessFromWire(settings) };
+  }
+
+  return {
+    ...typed,
+    tag,
+    sendThrough,
+    mux,
+    streamSettings,
+  };
+}
+
+// --- Form values -> wire payload --------------------------------------
+
+function vmessToWire(s: VmessOutboundFormSettings) {
+  return {
+    vnext: [{
+      address: s.address,
+      port: s.port,
+      users: [{ id: s.id, security: s.security }],
+    }],
+  };
+}
+
+function reverseSniffingToWire(s: ReverseSniffingForm) {
+  return {
+    enabled: s.enabled,
+    destOverride: s.destOverride,
+    metadataOnly: s.metadataOnly,
+    routeOnly: s.routeOnly,
+    ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
+    domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
+  };
+}
+
+function vlessToWire(s: VlessOutboundFormSettings) {
+  const result: Raw = {
+    address: s.address,
+    port: s.port,
+    id: s.id,
+    flow: s.flow,
+    encryption: s.encryption || 'none',
+  };
+  if (s.reverseTag) {
+    const sn = reverseSniffingToWire(s.reverseSniffing);
+    const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
+    result.reverse = {
+      tag: s.reverseTag,
+      sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
+    };
+  }
+  if (s.flow === 'xtls-rprx-vision') {
+    if (s.testpre > 0) result.testpre = s.testpre;
+    if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
+      result.testseed = s.testseed;
+    }
+  }
+  return result;
+}
+
+function trojanToWire(s: TrojanOutboundFormSettings) {
+  return { servers: [{ address: s.address, port: s.port, password: s.password }] };
+}
+
+function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
+  return {
+    servers: [{
+      address: s.address,
+      port: s.port,
+      password: s.password,
+      method: s.method,
+      uot: s.uot,
+      UoTVersion: s.UoTVersion,
+    }],
+  };
+}
+
+function simpleAuthToWire(s: SimpleAuthFormSettings) {
+  return {
+    servers: [{
+      address: s.address,
+      port: s.port,
+      users: s.user ? [{ user: s.user, pass: s.pass }] : [],
+    }],
+  };
+}
+
+function wireguardToWire(s: WireguardOutboundFormSettings) {
+  return {
+    mtu: s.mtu || undefined,
+    secretKey: s.secretKey,
+    address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
+    workers: s.workers || undefined,
+    domainStrategy: s.domainStrategy || undefined,
+    reserved: s.reserved
+      ? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
+      : undefined,
+    peers: s.peers.map((p) => ({
+      publicKey: p.publicKey,
+      preSharedKey: p.psk.length > 0 ? p.psk : undefined,
+      allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
+      endpoint: p.endpoint,
+      keepAlive: p.keepAlive || undefined,
+    })),
+    noKernelTun: s.noKernelTun,
+  };
+}
+
+function hysteriaToWire(s: HysteriaOutboundFormSettings) {
+  return { address: s.address, port: s.port, version: s.version };
+}
+
+function freedomToWire(s: FreedomOutboundFormSettings) {
+  // Legacy semantics: emit fragment only when the user actually populated
+  // at least one of the four sub-fields. Defaults like packets='1-3' alone
+  // are not enough — the modal's Fragment Switch sets all four together.
+  const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
+  const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
+  return {
+    domainStrategy: s.domainStrategy || undefined,
+    redirect: s.redirect || undefined,
+    fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
+    noises: s.noises.length > 0 ? s.noises : undefined,
+    finalRules: s.finalRules.length > 0
+      ? s.finalRules.map((r) => ({
+          action: r.action,
+          network: r.network || undefined,
+          port: r.port || undefined,
+          ip: r.ip.length > 0 ? r.ip : undefined,
+          blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
+        }))
+      : undefined,
+  };
+}
+
+function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
+  return { response: s.type ? { type: s.type } : undefined };
+}
+
+function dnsRuleToWire(r: DnsRuleForm) {
+  const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action)
+    ? r.action
+    : 'direct';
+  const result: Raw = { action };
+  const qtype = r.qtype.trim();
+  if (qtype) {
+    result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype;
+  }
+  const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
+  if (domains.length > 0) result.domain = domains;
+  return result;
+}
+
+function dnsToWire(s: DnsOutboundFormSettings) {
+  const result: Raw = {};
+  if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
+  if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
+  if (s.rewritePort) result.rewritePort = s.rewritePort;
+  if (s.userLevel) result.userLevel = s.userLevel;
+  if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
+  return result;
+}
+
+function loopbackToWire(s: LoopbackOutboundFormSettings) {
+  return { inboundTag: s.inboundTag || undefined };
+}
+
+// canEnableMux mirrors the legacy Outbound.canEnableMux().
+const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
+const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
+
+function muxAllowed(values: OutboundFormValues): boolean {
+  if (!MUX_PROTOCOLS.has(values.protocol)) return false;
+  const flow = values.protocol === 'vless'
+    ? (values.settings as VlessOutboundFormSettings).flow
+    : '';
+  if (flow) return false;
+  const network = values.streamSettings && 'network' in values.streamSettings
+    ? values.streamSettings.network
+    : undefined;
+  if (network === 'xhttp') return false;
+  return true;
+}
+
+export type WireOutboundPayload = Raw;
+
+export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
+  let settings: Raw;
+  switch (values.protocol) {
+    case 'vmess':       settings = vmessToWire(values.settings); break;
+    case 'vless':       settings = vlessToWire(values.settings); break;
+    case 'trojan':      settings = trojanToWire(values.settings); break;
+    case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
+    case 'socks':       settings = simpleAuthToWire(values.settings); break;
+    case 'http':        settings = simpleAuthToWire(values.settings); break;
+    case 'wireguard':   settings = wireguardToWire(values.settings); break;
+    case 'hysteria':    settings = hysteriaToWire(values.settings); break;
+    case 'freedom':     settings = freedomToWire(values.settings); break;
+    case 'blackhole':   settings = blackholeToWire(values.settings); break;
+    case 'dns':         settings = dnsToWire(values.settings); break;
+    case 'loopback':    settings = loopbackToWire(values.settings); break;
+  }
+
+  const result: Raw = {
+    protocol: values.protocol,
+    settings,
+  };
+  if (values.tag) result.tag = values.tag;
+
+  // streamSettings emission gates on canEnableStream — non-stream protocols
+  // still emit just `sockopt` if that key is present (legacy behavior).
+  if (values.streamSettings) {
+    if (STREAM_PROTOCOLS.has(values.protocol)) {
+      result.streamSettings = values.streamSettings;
+    } else {
+      const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
+      if (sockopt) result.streamSettings = { sockopt };
+    }
+  }
+
+  if (values.sendThrough) result.sendThrough = values.sendThrough;
+  if (values.mux.enabled && muxAllowed(values)) {
+    result.mux = values.mux;
+  }
+  return result;
+}

+ 265 - 0
frontend/src/schemas/forms/outbound-form.ts

@@ -0,0 +1,265 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+import { VmessSecuritySchema } from '@/schemas/protocols/inbound/vmess';
+import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks';
+import { SecuritySettingsSchema } from '@/schemas/protocols/security';
+import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
+import {
+  BlackholeResponseTypeSchema,
+  DNSRuleActionSchema,
+  FreedomFinalRuleActionSchema,
+  FreedomFragmentSchema,
+  FreedomNoiseSchema,
+  OutboundDomainStrategySchema,
+  WireguardDomainStrategySchema,
+} from '@/schemas/protocols/outbound';
+
+// OutboundFormValues = the shape Form.useForm<T>() carries inside
+// OutboundFormModal. Differences from schemas/api wire schemas:
+//
+//   - vmess vnext / trojan-ss-socks-http servers are FLATTENED into
+//     {address, port, ...auth} at settings root. The adapter handles
+//     nesting on submit.
+//   - wireguard `address` (string[] wire) and `reserved` (number[] wire)
+//     are comma-joined STRINGS in the form. The adapter splits + coerces.
+//   - wireguard `pubKey` is a UI-only field derived from `secretKey`. Not
+//     emitted on the wire — the adapter strips it.
+//   - VLESS `reverseTag` and `reverseSniffing` are flat at settings root;
+//     the adapter wraps them as { reverse: { tag, sniffing } } on the wire.
+//   - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it
+//     as { response: { type } } on the wire (omitted when empty).
+//   - DNS rules carry `qtype` and `domain` as comma-joined strings (matches
+//     the legacy DNSRule UI). The adapter normalizes them on submit.
+//
+// All flat-form settings types are documented inline so the adapter has a
+// single source of truth for the shape it converts between.
+
+// VMess outbound: connect target (address+port) + first user (id+security).
+// Wire: { vnext: [{ address, port, users: [{ id, security }] }] }.
+export const VmessOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  id: z.string().default(''),
+  security: VmessSecuritySchema.default('auto'),
+});
+export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
+
+// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults
+// match legacy ReverseSniffing constructor.
+export const ReverseSniffingFormSchema = z.object({
+  enabled: z.boolean().default(false),
+  destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']),
+  metadataOnly: z.boolean().default(false),
+  routeOnly: z.boolean().default(false),
+  ipsExcluded: z.array(z.string()).default([]),
+  domainsExcluded: z.array(z.string()).default([]),
+});
+export type ReverseSniffingForm = z.infer<typeof ReverseSniffingFormSchema>;
+
+// VLESS outbound: flat connect target + auth + Vision-specific knobs +
+// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed.
+export const VlessOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  id: z.string().default(''),
+  flow: z.string().default(''),
+  encryption: z.literal('none').default('none'),
+  reverseTag: z.string().default(''),
+  reverseSniffing: ReverseSniffingFormSchema.default({
+    enabled: false,
+    destOverride: ['http', 'tls', 'quic', 'fakedns'],
+    metadataOnly: false,
+    routeOnly: false,
+    ipsExcluded: [],
+    domainsExcluded: [],
+  }),
+  testpre: z.number().int().min(0).default(0),
+  testseed: z.array(z.number().int().positive()).default([]),
+});
+export type VlessOutboundFormSettings = z.infer<typeof VlessOutboundFormSettingsSchema>;
+
+export const TrojanOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  password: z.string().default(''),
+});
+export type TrojanOutboundFormSettings = z.infer<typeof TrojanOutboundFormSettingsSchema>;
+
+export const ShadowsocksOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  password: z.string().default(''),
+  method: SSMethodSchema.default('2022-blake3-aes-128-gcm'),
+  uot: z.boolean().default(false),
+  UoTVersion: z.number().int().min(1).max(2).default(1),
+});
+export type ShadowsocksOutboundFormSettings = z.infer<typeof ShadowsocksOutboundFormSettingsSchema>;
+
+// SOCKS / HTTP: panel only supports a single server, with optionally one
+// user (the adapter emits users: [] when user is empty).
+export const SocksOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(1080),
+  user: z.string().default(''),
+  pass: z.string().default(''),
+});
+export type SocksOutboundFormSettings = z.infer<typeof SocksOutboundFormSettingsSchema>;
+
+export const HttpOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(8080),
+  user: z.string().default(''),
+  pass: z.string().default(''),
+});
+export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
+
+// Wireguard peer mirrors the legacy Outbound.WireguardSettings.Peer class.
+// `psk` (form) <-> `preSharedKey` (wire) — adapter renames.
+export const WireguardOutboundFormPeerSchema = z.object({
+  publicKey: z.string().default(''),
+  psk: z.string().default(''),
+  allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']),
+  endpoint: z.string().default(''),
+  keepAlive: z.number().int().min(0).default(0),
+});
+export type WireguardOutboundFormPeer = z.infer<typeof WireguardOutboundFormPeerSchema>;
+
+// Wireguard: `address` and `reserved` are comma-joined strings in the form
+// (the legacy UI binds them to a single Input). pubKey is UI-only — the
+// modal derives it from secretKey via Wireguard.generateKeypair() and
+// displays it disabled; the adapter strips it.
+export const WireguardOutboundFormSettingsSchema = z.object({
+  mtu: z.number().int().min(0).default(1420),
+  secretKey: z.string().default(''),
+  pubKey: z.string().default(''),
+  address: z.string().default(''),
+  workers: z.number().int().min(0).default(2),
+  domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
+  reserved: z.string().default(''),
+  peers: z.array(WireguardOutboundFormPeerSchema).default([]),
+  noKernelTun: z.boolean().default(false),
+});
+export type WireguardOutboundFormSettings = z.infer<typeof WireguardOutboundFormSettingsSchema>;
+
+// Hysteria outbound carries the connect target only; transport-layer knobs
+// (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria.
+export const HysteriaOutboundFormSettingsSchema = z.object({
+  address: z.string().default(''),
+  port: PortSchema.default(443),
+  version: z.literal(2).default(2),
+});
+export type HysteriaOutboundFormSettings = z.infer<typeof HysteriaOutboundFormSettingsSchema>;
+
+// FinalRule (freedom): network/port are strings; ip is string[]; blockDelay
+// is only meaningful when action === 'block'. The adapter omits empty
+// fields from the wire payload.
+export const FreedomFinalRuleFormSchema = z.object({
+  action: FreedomFinalRuleActionSchema.default('block'),
+  network: z.string().default(''),
+  port: z.string().default(''),
+  ip: z.array(z.string()).default([]),
+  blockDelay: z.string().default(''),
+});
+export type FreedomFinalRuleForm = z.infer<typeof FreedomFinalRuleFormSchema>;
+
+export const FreedomOutboundFormSettingsSchema = z.object({
+  domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
+  redirect: z.string().default(''),
+  fragment: FreedomFragmentSchema.default({
+    packets: '1-3',
+    length: '',
+    interval: '',
+    maxSplit: '',
+  }),
+  noises: z.array(FreedomNoiseSchema).default([]),
+  finalRules: z.array(FreedomFinalRuleFormSchema).default([]),
+});
+export type FreedomOutboundFormSettings = z.infer<typeof FreedomOutboundFormSettingsSchema>;
+
+// Blackhole: legacy form keeps `type` as a flat string ('' | 'none' | 'http');
+// adapter wraps as { response: { type } } on the wire and omits when empty.
+export const BlackholeOutboundFormSettingsSchema = z.object({
+  type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''),
+});
+export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
+
+// DNS rules: form holds qtype + domain as joined strings (the legacy UI
+// binds to <Input>). Adapter parses them on submit per the DNSRule class.
+export const DnsRuleFormSchema = z.object({
+  action: DNSRuleActionSchema.default('direct'),
+  qtype: z.string().default(''),
+  domain: z.string().default(''),
+});
+export type DnsRuleForm = z.infer<typeof DnsRuleFormSchema>;
+
+export const DnsOutboundFormSettingsSchema = z.object({
+  rewriteNetwork: z.union([z.enum(['udp', 'tcp']), z.literal('')]).default(''),
+  rewriteAddress: z.string().default(''),
+  rewritePort: z.number().int().min(0).max(65535).default(53),
+  userLevel: z.number().int().min(0).default(0),
+  rules: z.array(DnsRuleFormSchema).default([]),
+});
+export type DnsOutboundFormSettings = z.infer<typeof DnsOutboundFormSettingsSchema>;
+
+export const LoopbackOutboundFormSettingsSchema = z.object({
+  inboundTag: z.string().default(''),
+});
+export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
+
+// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
+// inbound side: each branch is { protocol: literal, settings: <flat> }.
+export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
+  z.object({ protocol: z.literal('vmess'),       settings: VmessOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('vless'),       settings: VlessOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('trojan'),      settings: TrojanOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('socks'),       settings: SocksOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('http'),        settings: HttpOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('wireguard'),   settings: WireguardOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('hysteria'),    settings: HysteriaOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('freedom'),     settings: FreedomOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('blackhole'),   settings: BlackholeOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('dns'),         settings: DnsOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('loopback'),    settings: LoopbackOutboundFormSettingsSchema }),
+]);
+export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
+
+// Mux ride: only emitted when enabled. The adapter respects canEnableMux
+// (gated by protocol + flow + network).
+export const MuxFormSchema = z.object({
+  enabled: z.boolean().default(false),
+  concurrency: z.number().int().default(8),
+  xudpConcurrency: z.number().int().default(16),
+  xudpProxyUDP443: z.enum(['reject', 'allow', 'skip']).default('reject'),
+});
+export type MuxForm = z.infer<typeof MuxFormSchema>;
+
+// Stream form mirrors the inbound side: NetworkSettings DU + SecuritySettings
+// DU + extras (sockopt). Hysteria gets a side-channel branch in the modal
+// (legacy ob.stream.hysteria) — keeping the DU strict for now and routing
+// hysteria transport knobs through the Advanced JSON tab if needed.
+export const OutboundStreamFormSchema = NetworkSettingsSchema
+  .and(SecuritySettingsSchema)
+  .and(StreamExtrasSchema);
+export type OutboundStreamFormValues = z.infer<typeof OutboundStreamFormSchema>;
+
+// Top-level form base: identity (tag, sendThrough), then the per-protocol
+// settings DU, then the stream sub-form, then mux.
+export const OutboundFormBaseSchema = z.object({
+  tag: z.string().default(''),
+  sendThrough: z.string().default(''),
+  streamSettings: OutboundStreamFormSchema.optional(),
+  mux: MuxFormSchema.default({
+    enabled: false,
+    concurrency: 8,
+    xudpConcurrency: 16,
+    xudpProxyUDP443: 'reject',
+  }),
+});
+export type OutboundFormBase = z.infer<typeof OutboundFormBaseSchema>;
+
+// Full form values = base + protocol-discriminated settings. Consumers
+// narrow on `.protocol` to access the matching settings branch.
+export const OutboundFormSchema = OutboundFormBaseSchema.and(OutboundFormSettingsSchema);
+export type OutboundFormValues = z.infer<typeof OutboundFormSchema>;

+ 302 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -0,0 +1,302 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  formValuesToWirePayload,
+  rawOutboundToFormValues,
+} from '@/lib/xray/outbound-form-adapter';
+
+// Round-trip parity: wire → form → wire should preserve the legacy
+// Outbound.fromJson(...).toJson() output shape for each protocol's quirks.
+// Spot-checking the cases the modal exercised in v0.x — vmess vnext flatten,
+// vless reverse-wrap, wireguard address csv ↔ array, freedom finalRules
+// emission, blackhole type wrap, dns rule normalization, mux gating.
+
+describe('outbound-form-adapter: round-trip', () => {
+  it('vmess flattens vnext to address/port/id/security and re-nests', () => {
+    const wire = {
+      protocol: 'vmess',
+      tag: 'outbound-vmess',
+      settings: {
+        vnext: [{
+          address: '1.2.3.4',
+          port: 443,
+          users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
+        }],
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    expect(form.protocol).toBe('vmess');
+    if (form.protocol === 'vmess') {
+      expect(form.settings.address).toBe('1.2.3.4');
+      expect(form.settings.port).toBe(443);
+      expect(form.settings.id).toBe('11111111-2222-4333-8444-555555555555');
+      expect(form.settings.security).toBe('auto');
+    }
+    const back = formValuesToWirePayload(form);
+    expect(back).toMatchObject({
+      protocol: 'vmess',
+      tag: 'outbound-vmess',
+      settings: {
+        vnext: [{
+          address: '1.2.3.4',
+          port: 443,
+          users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
+        }],
+      },
+    });
+  });
+
+  it('vless preserves flat shape and emits reverse only when reverseTag is set', () => {
+    const wire = {
+      protocol: 'vless',
+      tag: 'out-vless',
+      settings: {
+        address: 'srv.example',
+        port: 8443,
+        id: '11111111-2222-4333-8444-555555555555',
+        flow: 'xtls-rprx-vision',
+        encryption: 'none',
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    expect(form.protocol).toBe('vless');
+    if (form.protocol === 'vless') {
+      expect(form.settings.reverseTag).toBe('');
+    }
+    const back = formValuesToWirePayload(form);
+    expect(back.settings).not.toHaveProperty('reverse');
+    expect(back.settings).toMatchObject({
+      address: 'srv.example',
+      port: 8443,
+      id: '11111111-2222-4333-8444-555555555555',
+      flow: 'xtls-rprx-vision',
+      encryption: 'none',
+    });
+  });
+
+  it('vless emits reverse + sniffing when reverseTag is set', () => {
+    const wire = {
+      protocol: 'vless',
+      settings: {
+        address: 'srv',
+        port: 8443,
+        id: '11111111-2222-4333-8444-555555555555',
+        flow: '',
+        encryption: 'none',
+        reverse: { tag: 'rev-1', sniffing: { enabled: true, destOverride: ['tls'] } },
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    if (form.protocol === 'vless') {
+      expect(form.settings.reverseTag).toBe('rev-1');
+      expect(form.settings.reverseSniffing.enabled).toBe(true);
+      expect(form.settings.reverseSniffing.destOverride).toEqual(['tls']);
+    }
+    const back = formValuesToWirePayload(form);
+    const settings = back.settings as Record<string, unknown>;
+    expect(settings.reverse).toMatchObject({ tag: 'rev-1' });
+  });
+
+  it('vless does not emit testpre/testseed unless flow is vision', () => {
+    const wire = {
+      protocol: 'vless',
+      settings: {
+        address: 'srv', port: 443, id: '11111111-2222-4333-8444-555555555555',
+        flow: '', encryption: 'none', testpre: 5, testseed: [1, 2, 3, 4],
+      },
+    };
+    const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
+    expect(back.settings).not.toHaveProperty('testpre');
+    expect(back.settings).not.toHaveProperty('testseed');
+  });
+
+  it('trojan flattens servers[0] and re-nests', () => {
+    const wire = {
+      protocol: 'trojan',
+      settings: { servers: [{ address: 's', port: 443, password: 'pw' }] },
+    };
+    const form = rawOutboundToFormValues(wire);
+    if (form.protocol === 'trojan') {
+      expect(form.settings).toEqual({ address: 's', port: 443, password: 'pw' });
+    }
+    expect(formValuesToWirePayload(form).settings).toEqual({
+      servers: [{ address: 's', port: 443, password: 'pw' }],
+    });
+  });
+
+  it('shadowsocks preserves uot + UoTVersion', () => {
+    const wire = {
+      protocol: 'shadowsocks',
+      settings: {
+        servers: [{
+          address: 's', port: 443, password: 'pw',
+          method: '2022-blake3-aes-128-gcm', uot: true, UoTVersion: 2,
+        }],
+      },
+    };
+    const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
+    expect(back.settings).toMatchObject({
+      servers: [{ uot: true, UoTVersion: 2 }],
+    });
+  });
+
+  it('socks emits users:[] when user is empty, users:[{...}] when set', () => {
+    const noUser = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'socks',
+      settings: { servers: [{ address: 's', port: 1080 }] },
+    }));
+    expect(noUser.settings).toMatchObject({ servers: [{ users: [] }] });
+
+    const withUser = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'socks',
+      settings: { servers: [{ address: 's', port: 1080, users: [{ user: 'u', pass: 'p' }] }] },
+    }));
+    expect(withUser.settings).toMatchObject({
+      servers: [{ users: [{ user: 'u', pass: 'p' }] }],
+    });
+  });
+
+  it('wireguard csv-joins address and reserved on read, splits on write', () => {
+    const wire = {
+      protocol: 'wireguard',
+      settings: {
+        mtu: 1420,
+        secretKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
+        address: ['10.0.0.1', 'fd00::1'],
+        workers: 2,
+        peers: [{ publicKey: 'pk', allowedIPs: ['0.0.0.0/0'], endpoint: 'e:51820', preSharedKey: 'psk' }],
+        reserved: [1, 2, 3],
+        noKernelTun: false,
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    if (form.protocol === 'wireguard') {
+      expect(form.settings.address).toBe('10.0.0.1,fd00::1');
+      expect(form.settings.reserved).toBe('1,2,3');
+      expect(form.settings.peers[0].psk).toBe('psk');
+    }
+    const back = formValuesToWirePayload(form);
+    expect(back.settings).toMatchObject({
+      address: ['10.0.0.1', 'fd00::1'],
+      reserved: [1, 2, 3],
+      peers: [{ preSharedKey: 'psk' }],
+    });
+  });
+
+  it('blackhole wraps type into {response:{type}} and omits when empty', () => {
+    const empty = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'blackhole',
+      settings: {},
+    }));
+    expect(empty.settings).toEqual({ response: undefined });
+
+    const withType = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'blackhole',
+      settings: { response: { type: 'http' } },
+    }));
+    expect(withType.settings).toEqual({ response: { type: 'http' } });
+  });
+
+  it('dns rules normalize qtype numeric strings and split domains', () => {
+    const wire = {
+      protocol: 'dns',
+      settings: {
+        rewriteNetwork: 'udp',
+        rewriteAddress: '1.1.1.1',
+        rewritePort: 53,
+        rules: [
+          { action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] },
+          { action: 'reject', qtype: 28, domain: 'blocked.com' },
+        ],
+      },
+    };
+    const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
+    const settings = back.settings as Record<string, unknown>;
+    const rules = settings.rules as Array<Record<string, unknown>>;
+    expect(rules[0]).toEqual({ action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] });
+    expect(rules[1]).toEqual({ action: 'reject', qtype: 28, domain: ['blocked.com'] });
+  });
+
+  it('freedom emits domainStrategy/redirect/fragment conditionally', () => {
+    const empty = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: {},
+    }));
+    expect(empty.settings).toEqual({
+      domainStrategy: undefined,
+      redirect: undefined,
+      fragment: undefined,
+      noises: undefined,
+      finalRules: undefined,
+    });
+
+    const filled = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: {
+        domainStrategy: 'UseIPv4',
+        redirect: '1.1.1.1',
+        fragment: { packets: 'tlshello', length: '100-200' },
+      },
+    }));
+    expect(filled.settings).toMatchObject({
+      domainStrategy: 'UseIPv4',
+      redirect: '1.1.1.1',
+      fragment: { packets: 'tlshello', length: '100-200' },
+    });
+  });
+
+  it('mux is only emitted when enabled AND protocol/network/flow allow it', () => {
+    // Disabled mux: omitted
+    const disabled = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'vless',
+      settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
+      mux: { enabled: false },
+    }));
+    expect(disabled).not.toHaveProperty('mux');
+
+    // Enabled mux on vless without flow: emitted
+    const enabled = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'vless',
+      settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
+      mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' },
+    }));
+    expect(enabled.mux).toMatchObject({ enabled: true });
+
+    // Enabled mux on vless with vision flow: gated out
+    const withFlow = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'vless',
+      settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: 'xtls-rprx-vision', encryption: 'none' },
+      mux: { enabled: true },
+    }));
+    expect(withFlow).not.toHaveProperty('mux');
+
+    // Freedom (non-mux protocol): gated out even if enabled
+    const freedom = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: {},
+      mux: { enabled: true },
+    }));
+    expect(freedom).not.toHaveProperty('mux');
+  });
+
+  it('hysteria preserves address/port/version literal 2', () => {
+    const back = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'hysteria',
+      settings: { address: 'h.example', port: 8443, version: 2 },
+    }));
+    expect(back.settings).toEqual({ address: 'h.example', port: 8443, version: 2 });
+  });
+
+  it('loopback inboundTag round-trips', () => {
+    const back = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'loopback',
+      settings: { inboundTag: 'tagged-inbound' },
+    }));
+    expect(back.settings).toEqual({ inboundTag: 'tagged-inbound' });
+  });
+
+  it('unknown protocol falls back to vless without throwing', () => {
+    const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
+    expect(form.protocol).toBe('vless');
+  });
+});