mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +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:
parent
142ed97cc0
commit
e2784fcf3f
2 changed files with 419 additions and 0 deletions
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue