diff --git a/frontend/src/lib/xray/outbound-defaults.ts b/frontend/src/lib/xray/outbound-defaults.ts new file mode 100644 index 00000000..c979eb26 --- /dev/null +++ b/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.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; + } +} diff --git a/frontend/src/test/outbound-defaults.test.ts b/frontend/src/test/outbound-defaults.test.ts new file mode 100644 index 00000000..b5d467e7 --- /dev/null +++ b/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.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(); + }); +});