mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 17:39:35 +00:00
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:
174
frontend/src/lib/xray/outbound-defaults.ts
Normal file
174
frontend/src/lib/xray/outbound-defaults.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
245
frontend/src/test/outbound-defaults.test.ts
Normal file
245
frontend/src/test/outbound-defaults.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user