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)
This commit is contained in:
MHSanaei
2026-05-26 01:58:07 +02:00
parent 142ed97cc0
commit e2784fcf3f
2 changed files with 419 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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();
});
});