Parcourir la source

feat(frontend): outbound settings factories + dispatcher

Adds lib/xray/outbound-defaults.ts parallel to inbound-defaults.ts:
13 createDefault*OutboundSettings factories (one per outbound protocol)
plus the createDefaultOutboundSettings(protocol) dispatcher mirroring
Outbound.Settings.getSettings's contract — non-null on each known
protocol, null otherwise.

The factory output matches the legacy `new Outbound.<X>Settings()` start
state: required-by-schema fields the user fills in via the form
(address, port, password, id, peer publicKey/endpoint) come back as
empty stubs. Wireguard alone seeds secretKey via the X25519 generator;
the rest expose blank fields. This is the same behavior the
OutboundFormModal relies on for protocol-change resets.

Shadowsocks defaults to 2022-blake3-aes-128-gcm rather than the legacy
undefined — the Select snaps to the first option anyway, so the
coherent default keeps the modal from rendering an empty picker.

Tests cover three layers:
- exact-shape snapshots per factory (13 cases)
- Zod schema acceptance after sensible stub fill-in (13 cases)
- dispatcher non-null per known protocol + null for the unknown (14 cases)
MHSanaei il y a 20 heures
Parent
commit
e2784fcf3f

+ 174 - 0
frontend/src/lib/xray/outbound-defaults.ts

@@ -0,0 +1,174 @@
+import { RandomUtil, Wireguard } from '@/utils';
+
+import type { BlackholeOutboundSettings } from '@/schemas/protocols/outbound/blackhole';
+import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
+import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
+import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
+import type { Hysteria2OutboundSettings } from '@/schemas/protocols/outbound/hysteria2';
+import type { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
+import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
+import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
+import type { SocksOutboundSettings } from '@/schemas/protocols/outbound/socks';
+import type { TrojanOutboundSettings } from '@/schemas/protocols/outbound/trojan';
+import type { VlessOutboundSettings } from '@/schemas/protocols/outbound/vless';
+import type { VmessOutboundSettings } from '@/schemas/protocols/outbound/vmess';
+import type { WireguardOutboundSettings } from '@/schemas/protocols/outbound/wireguard';
+
+// Plain-object factories mirroring `new Outbound.<X>Settings()` from the
+// legacy class hierarchy, then `.toJson()`. The output matches the wire
+// shape — the same starting state the OutboundFormModal's `ob.settings`
+// holds the first time the user picks a protocol.
+//
+// Required-by-schema fields the legacy class leaves undefined (address,
+// port, user-supplied ids/passwords) become empty stubs here. Zod will
+// reject the default output until the user fills them in via the form;
+// this is intentional and matches the legacy "scaffold object" behavior.
+
+export function createDefaultFreedomOutboundSettings(): FreedomOutboundSettings {
+  return {};
+}
+
+export function createDefaultBlackholeOutboundSettings(): BlackholeOutboundSettings {
+  return {};
+}
+
+export function createDefaultLoopbackOutboundSettings(): LoopbackOutboundSettings {
+  return { inboundTag: '' };
+}
+
+export function createDefaultDNSOutboundSettings(): DNSOutboundSettings {
+  return {
+    rewriteNetwork: '',
+    rewriteAddress: '',
+    rewritePort: 53,
+    userLevel: 0,
+    rules: [],
+  };
+}
+
+export function createDefaultVmessOutboundSettings(): VmessOutboundSettings {
+  return {
+    vnext: [{
+      address: '',
+      port: 443,
+      users: [{ id: '', security: 'auto' }],
+    }],
+  };
+}
+
+export function createDefaultVlessOutboundSettings(): VlessOutboundSettings {
+  return {
+    address: '',
+    port: 443,
+    id: '',
+    flow: '',
+    encryption: 'none',
+  };
+}
+
+export function createDefaultTrojanOutboundSettings(): TrojanOutboundSettings {
+  return {
+    servers: [{ address: '', port: 443, password: '' }],
+  };
+}
+
+// Why: legacy constructor leaves method undefined; the form's Select
+// snaps to the first option when the user opens it. We pick the same
+// modern default the inbound shadowsocks factory uses
+// (2022-blake3-aes-128-gcm) so the OutboundFormModal renders a coherent
+// initial state instead of an empty Select.
+export function createDefaultShadowsocksOutboundSettings(): ShadowsocksOutboundSettings {
+  return {
+    servers: [{
+      address: '',
+      port: 443,
+      password: '',
+      method: '2022-blake3-aes-128-gcm',
+    }],
+  };
+}
+
+export function createDefaultSocksOutboundSettings(): SocksOutboundSettings {
+  return {
+    servers: [{ address: '', port: 1080, users: [] }],
+  };
+}
+
+export function createDefaultHttpOutboundSettings(): HttpOutboundSettings {
+  return {
+    servers: [{ address: '', port: 8080, users: [] }],
+  };
+}
+
+interface WireguardOutboundSeed {
+  secretKey?: string;
+}
+
+export function createDefaultWireguardOutboundSettings(
+  seed: WireguardOutboundSeed = {},
+): WireguardOutboundSettings {
+  const secretKey = seed.secretKey ?? Wireguard.generateKeypair().privateKey;
+  return {
+    mtu: 1420,
+    secretKey,
+    address: [],
+    workers: 2,
+    peers: [{
+      publicKey: '',
+      allowedIPs: ['0.0.0.0/0', '::/0'],
+      endpoint: '',
+    }],
+    noKernelTun: false,
+  };
+}
+
+export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSettings {
+  return { address: '', port: 443, version: 2 };
+}
+
+export function createDefaultHysteria2OutboundSettings(): Hysteria2OutboundSettings {
+  return { address: '', port: 443, version: 2 };
+}
+
+export type AnyOutboundSettings =
+  | BlackholeOutboundSettings
+  | DNSOutboundSettings
+  | FreedomOutboundSettings
+  | HttpOutboundSettings
+  | HysteriaOutboundSettings
+  | Hysteria2OutboundSettings
+  | LoopbackOutboundSettings
+  | ShadowsocksOutboundSettings
+  | SocksOutboundSettings
+  | TrojanOutboundSettings
+  | VlessOutboundSettings
+  | VmessOutboundSettings
+  | WireguardOutboundSettings;
+
+// Protocol-aware dispatch. Mirrors the legacy
+// `Outbound.Settings.getSettings(protocol)` switch. Note: the inbound
+// dispatcher returns `null` for unknown protocols and so does this one,
+// keeping the contract identical so callers can stay protocol-agnostic.
+//
+// The `RandomUtil` reference is held to silence unused-import warnings
+// when no per-call randomization happens at the dispatcher level —
+// individual factories may pull from it via their own seeds.
+export function createDefaultOutboundSettings(protocol: string): AnyOutboundSettings | null {
+  void RandomUtil;
+  switch (protocol) {
+    case 'freedom':     return createDefaultFreedomOutboundSettings();
+    case 'blackhole':   return createDefaultBlackholeOutboundSettings();
+    case 'dns':         return createDefaultDNSOutboundSettings();
+    case 'vmess':       return createDefaultVmessOutboundSettings();
+    case 'vless':       return createDefaultVlessOutboundSettings();
+    case 'trojan':      return createDefaultTrojanOutboundSettings();
+    case 'shadowsocks': return createDefaultShadowsocksOutboundSettings();
+    case 'socks':       return createDefaultSocksOutboundSettings();
+    case 'http':        return createDefaultHttpOutboundSettings();
+    case 'wireguard':   return createDefaultWireguardOutboundSettings();
+    case 'hysteria':    return createDefaultHysteriaOutboundSettings();
+    case 'hysteria2':   return createDefaultHysteria2OutboundSettings();
+    case 'loopback':    return createDefaultLoopbackOutboundSettings();
+    default:            return null;
+  }
+}

+ 245 - 0
frontend/src/test/outbound-defaults.test.ts

@@ -0,0 +1,245 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  createDefaultBlackholeOutboundSettings,
+  createDefaultDNSOutboundSettings,
+  createDefaultFreedomOutboundSettings,
+  createDefaultHttpOutboundSettings,
+  createDefaultHysteria2OutboundSettings,
+  createDefaultHysteriaOutboundSettings,
+  createDefaultLoopbackOutboundSettings,
+  createDefaultShadowsocksOutboundSettings,
+  createDefaultSocksOutboundSettings,
+  createDefaultTrojanOutboundSettings,
+  createDefaultVlessOutboundSettings,
+  createDefaultVmessOutboundSettings,
+  createDefaultWireguardOutboundSettings,
+  createDefaultOutboundSettings,
+} from '@/lib/xray/outbound-defaults';
+import {
+  BlackholeOutboundSettingsSchema,
+  DNSOutboundSettingsSchema,
+  FreedomOutboundSettingsSchema,
+  HttpOutboundSettingsSchema,
+  Hysteria2OutboundSettingsSchema,
+  HysteriaOutboundSettingsSchema,
+  LoopbackOutboundSettingsSchema,
+  ShadowsocksOutboundSettingsSchema,
+  SocksOutboundSettingsSchema,
+  TrojanOutboundSettingsSchema,
+  VlessOutboundSettingsSchema,
+  VmessOutboundSettingsSchema,
+  WireguardOutboundSettingsSchema,
+} from '@/schemas/protocols/outbound';
+
+// Snapshot + Zod round-trip for each createDefault*OutboundSettings factory.
+// The factory output mirrors the legacy `new Outbound.<X>Settings()` start
+// state, so most required fields are empty stubs (address, port, password,
+// id). Zod parsing happens AFTER patching the stubs with sensible values —
+// this catches schema/factory drift without forcing the factory to invent
+// data it shouldn't.
+
+const SAMPLE_ID = '11111111-2222-4333-8444-555555555555';
+const SAMPLE_ADDRESS = '1.2.3.4';
+const SAMPLE_PORT = 443;
+const SAMPLE_SECRET = 'abc123def456ghi789';
+
+describe('outbound default factories: shape snapshots', () => {
+  it('freedom is the empty object', () => {
+    expect(createDefaultFreedomOutboundSettings()).toEqual({});
+  });
+
+  it('blackhole is the empty object', () => {
+    expect(createDefaultBlackholeOutboundSettings()).toEqual({});
+  });
+
+  it('loopback has an empty inboundTag', () => {
+    expect(createDefaultLoopbackOutboundSettings()).toEqual({ inboundTag: '' });
+  });
+
+  it('dns has the legacy constructor defaults', () => {
+    expect(createDefaultDNSOutboundSettings()).toEqual({
+      rewriteNetwork: '',
+      rewriteAddress: '',
+      rewritePort: 53,
+      userLevel: 0,
+      rules: [],
+    });
+  });
+
+  it('vmess wraps a single vnext server with one user', () => {
+    expect(createDefaultVmessOutboundSettings()).toEqual({
+      vnext: [{ address: '', port: 443, users: [{ id: '', security: 'auto' }] }],
+    });
+  });
+
+  it('vless lays the connect target flat', () => {
+    expect(createDefaultVlessOutboundSettings()).toEqual({
+      address: '',
+      port: 443,
+      id: '',
+      flow: '',
+      encryption: 'none',
+    });
+  });
+
+  it('trojan wraps a single server', () => {
+    expect(createDefaultTrojanOutboundSettings()).toEqual({
+      servers: [{ address: '', port: 443, password: '' }],
+    });
+  });
+
+  it('shadowsocks defaults to 2022-blake3-aes-128-gcm', () => {
+    expect(createDefaultShadowsocksOutboundSettings()).toEqual({
+      servers: [{
+        address: '', port: 443, password: '', method: '2022-blake3-aes-128-gcm',
+      }],
+    });
+  });
+
+  it('socks defaults to port 1080 with no users', () => {
+    expect(createDefaultSocksOutboundSettings()).toEqual({
+      servers: [{ address: '', port: 1080, users: [] }],
+    });
+  });
+
+  it('http defaults to port 8080 with no users', () => {
+    expect(createDefaultHttpOutboundSettings()).toEqual({
+      servers: [{ address: '', port: 8080, users: [] }],
+    });
+  });
+
+  it('wireguard seeds secretKey deterministically when given', () => {
+    const out = createDefaultWireguardOutboundSettings({ secretKey: SAMPLE_SECRET });
+    expect(out.secretKey).toBe(SAMPLE_SECRET);
+    expect(out.mtu).toBe(1420);
+    expect(out.workers).toBe(2);
+    expect(out.address).toEqual([]);
+    expect(out.noKernelTun).toBe(false);
+    expect(out.peers).toEqual([{
+      publicKey: '', allowedIPs: ['0.0.0.0/0', '::/0'], endpoint: '',
+    }]);
+  });
+
+  it('wireguard generates a secretKey when none is seeded', () => {
+    const out = createDefaultWireguardOutboundSettings();
+    expect(out.secretKey).toMatch(/^[A-Za-z0-9+/=]+$/);
+    expect(out.secretKey.length).toBeGreaterThan(8);
+  });
+
+  it('hysteria defaults to port 443 version 2', () => {
+    expect(createDefaultHysteriaOutboundSettings()).toEqual({
+      address: '', port: 443, version: 2,
+    });
+  });
+
+  it('hysteria2 mirrors hysteria with literal version 2', () => {
+    expect(createDefaultHysteria2OutboundSettings()).toEqual({
+      address: '', port: 443, version: 2,
+    });
+  });
+});
+
+describe('outbound default factories: schema acceptance after stub fill-in', () => {
+  it('freedom default parses (no required fields)', () => {
+    expect(FreedomOutboundSettingsSchema.safeParse(
+      createDefaultFreedomOutboundSettings(),
+    ).success).toBe(true);
+  });
+
+  it('blackhole default parses (no required fields)', () => {
+    expect(BlackholeOutboundSettingsSchema.safeParse(
+      createDefaultBlackholeOutboundSettings(),
+    ).success).toBe(true);
+  });
+
+  it('loopback default parses (no required fields)', () => {
+    expect(LoopbackOutboundSettingsSchema.safeParse(
+      createDefaultLoopbackOutboundSettings(),
+    ).success).toBe(true);
+  });
+
+  it('dns default parses', () => {
+    expect(DNSOutboundSettingsSchema.safeParse(
+      createDefaultDNSOutboundSettings(),
+    ).success).toBe(true);
+  });
+
+  it('vmess parses once vnext fields are filled', () => {
+    const def = createDefaultVmessOutboundSettings();
+    def.vnext[0].address = SAMPLE_ADDRESS;
+    def.vnext[0].port = SAMPLE_PORT;
+    def.vnext[0].users[0].id = SAMPLE_ID;
+    expect(VmessOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('vless parses once address/port/id are filled', () => {
+    const def = createDefaultVlessOutboundSettings();
+    def.address = SAMPLE_ADDRESS;
+    def.port = SAMPLE_PORT;
+    def.id = SAMPLE_ID;
+    expect(VlessOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('trojan parses once server fields are filled', () => {
+    const def = createDefaultTrojanOutboundSettings();
+    def.servers[0].address = SAMPLE_ADDRESS;
+    def.servers[0].password = 'secret';
+    expect(TrojanOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('shadowsocks parses once server fields are filled', () => {
+    const def = createDefaultShadowsocksOutboundSettings();
+    def.servers[0].address = SAMPLE_ADDRESS;
+    def.servers[0].password = 'secret';
+    expect(ShadowsocksOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('socks parses once address is filled', () => {
+    const def = createDefaultSocksOutboundSettings();
+    def.servers[0].address = SAMPLE_ADDRESS;
+    expect(SocksOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('http parses once address is filled', () => {
+    const def = createDefaultHttpOutboundSettings();
+    def.servers[0].address = SAMPLE_ADDRESS;
+    expect(HttpOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('wireguard parses once peer + secretKey are filled', () => {
+    const def = createDefaultWireguardOutboundSettings({ secretKey: SAMPLE_SECRET });
+    def.peers[0].publicKey = 'pk';
+    def.peers[0].endpoint = `${SAMPLE_ADDRESS}:51820`;
+    expect(WireguardOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('hysteria parses once address is filled', () => {
+    const def = createDefaultHysteriaOutboundSettings();
+    def.address = SAMPLE_ADDRESS;
+    expect(HysteriaOutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+
+  it('hysteria2 parses once address is filled', () => {
+    const def = createDefaultHysteria2OutboundSettings();
+    def.address = SAMPLE_ADDRESS;
+    expect(Hysteria2OutboundSettingsSchema.safeParse(def).success).toBe(true);
+  });
+});
+
+describe('createDefaultOutboundSettings dispatcher', () => {
+  const PROTOCOLS = [
+    'freedom', 'blackhole', 'dns', 'vmess', 'vless', 'trojan', 'shadowsocks',
+    'socks', 'http', 'wireguard', 'hysteria', 'hysteria2', 'loopback',
+  ];
+
+  for (const protocol of PROTOCOLS) {
+    it(`returns non-null for ${protocol}`, () => {
+      expect(createDefaultOutboundSettings(protocol)).not.toBeNull();
+    });
+  }
+
+  it('returns null for an unknown protocol', () => {
+    expect(createDefaultOutboundSettings('mysterious')).toBeNull();
+  });
+});