Files
3x-ui/frontend/src/lib/xray/inbound-defaults.ts
T
Kuzz007 71dc453970 feat(amneziawg): make the Xray TPROXY bridge a per-inbound opt-in
Addresses Finding 10 from the automated PR review: an always-on TPROXY
bridge makes every AmneziaWG tunnel hard-depend on Xray being up (all
traffic, including DNS, drops whenever Xray restarts), and forces a full
awg-quick down+up bounce on any client add/remove/re-IP, permanently
losing the syncconf fast path.

Adds ServerSettings.RouteThroughXray (off by default):

- defaultPostUpDown only emits the TPROXY/policy-route rules when it's
  on; a plain AmneziaWG tunnel now has zero Xray dependency out of the
  box.
- structuralFingerprint covers it (toggling it changes whether PostUp/
  PostDown contain any TPROXY rules at all -- structural, not a
  per-peer host-rule). hostRulesFingerprint's IPv4 tracking is now
  itself conditional on RouteThroughXray (and IPv6 tracking on
  IPv6Enabled), so an instance that never uses either keeps the
  syncconf fast path for a plain peer re-IP.
- injectAmneziawgEgress only creates a bridge for inbounds that opted
  in; checkAmneziawgEgressConflict (the Finding-7 fix) now parses each
  candidate through InstanceFromInbound so a non-routed inbound's port
  is correctly never treated as reserved.
- New inbound-level Switch in the AmneziaWG form; the actual outbound
  decision is still made entirely through the panel's stock Routing
  page, same as before -- only whether the bridge exists at all is now
  a choice.

Translation keys added to all 13 locales in the same commit this time,
not backfilled later (see Finding 9's lesson).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 00:39:32 +03:00

355 lines
12 KiB
TypeScript

import { RandomUtil, Wireguard } from '@/utils';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { MtprotoClient, MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
// Plain-object factories for protocol clients. Each returns a Zod-parsable
// object matching the wire shape. Random fields (id, password, auth,
// email, subId) call RandomUtil at invocation time — pass them in
// `overrides` for deterministic tests or for forms that pre-seed values.
//
// These replace the legacy `new Inbound.<Settings>.<Client>()` constructors
// and the Inbound.ClientBase machinery. Callers no longer carry the
// XrayCommonClass dependency once the swap lands.
interface ClientBaseSeed {
email?: string;
subId?: string;
limitIp?: number;
totalGB?: number;
expiryTime?: number;
enable?: boolean;
tgId?: number;
comment?: string;
reset?: number;
}
interface ClientBase {
email: string;
limitIp: number;
totalGB: number;
expiryTime: number;
enable: boolean;
tgId: number;
subId: string;
comment: string;
reset: number;
}
function clientBase(seed: ClientBaseSeed = {}): ClientBase {
return {
email: seed.email ?? RandomUtil.randomLowerAndNum(10),
limitIp: seed.limitIp ?? 0,
totalGB: seed.totalGB ?? 0,
expiryTime: seed.expiryTime ?? 0,
enable: seed.enable ?? true,
tgId: seed.tgId ?? 0,
subId: seed.subId ?? RandomUtil.randomLowerAndNum(16),
comment: seed.comment ?? '',
reset: seed.reset ?? 0,
};
}
export interface VlessClientSeed extends ClientBaseSeed {
id?: string;
flow?: VlessClient['flow'];
}
export function createDefaultVlessClient(seed: VlessClientSeed = {}): VlessClient {
return {
id: seed.id ?? RandomUtil.randomUUID(),
flow: seed.flow ?? '',
...clientBase(seed),
};
}
export interface VmessClientSeed extends ClientBaseSeed {
id?: string;
security?: VmessClient['security'];
}
export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClient {
return {
id: seed.id ?? RandomUtil.randomUUID(),
security: seed.security ?? 'auto',
alterId: 0,
...clientBase(seed),
};
}
export interface TrojanClientSeed extends ClientBaseSeed {
password?: string;
}
export function createDefaultTrojanClient(seed: TrojanClientSeed = {}): TrojanClient {
return {
password: seed.password ?? RandomUtil.randomSeq(10),
...clientBase(seed),
};
}
export interface ShadowsocksClientSeed extends ClientBaseSeed {
method?: string;
password?: string;
ssMethod?: string;
}
// Shadowsocks clients ship with an empty `method` on single-user inbounds
// (the parent inbound's method is authoritative); only 2022-blake3 multi-
// user inbounds use the per-client method. Callers pass `ssMethod` to seed
// a method-specific password length when creating a multi-user client.
export function createDefaultShadowsocksClient(seed: ShadowsocksClientSeed = {}): ShadowsocksClient {
const method = seed.method ?? '';
const password = seed.password ?? RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
return {
method,
password,
...clientBase(seed),
};
}
export interface HysteriaClientSeed extends ClientBaseSeed {
auth?: string;
}
export function createDefaultHysteriaClient(seed: HysteriaClientSeed = {}): HysteriaClient {
return {
auth: seed.auth ?? RandomUtil.randomSeq(10),
...clientBase(seed),
};
}
// Inbound-settings factories. Each returns a Zod-parsable wire-shape with
// schema defaults already applied — no class instance, no XrayCommonClass.
// Callers (form modals via Step 4, InboundsPage clone via Step 5) call
// these instead of the legacy `Inbound.Settings.getSettings(protocol)`.
export function createDefaultVlessInboundSettings(): VlessInboundSettings {
return {
clients: [],
decryption: 'none',
encryption: 'none',
fallbacks: [],
};
}
export function createDefaultVmessInboundSettings(): VmessInboundSettings {
return { clients: [] };
}
export function createDefaultTrojanInboundSettings(): TrojanInboundSettings {
return { clients: [], fallbacks: [] };
}
export interface ShadowsocksInboundSeed {
method?: ShadowsocksInboundSettings['method'];
password?: string;
network?: ShadowsocksInboundSettings['network'];
ivCheck?: boolean;
}
export function createDefaultShadowsocksInboundSettings(
seed: ShadowsocksInboundSeed = {},
): ShadowsocksInboundSettings {
const method = seed.method ?? '2022-blake3-aes-256-gcm';
return {
method,
password: seed.password ?? RandomUtil.randomShadowsocksPassword(method),
network: seed.network ?? 'tcp,udp',
clients: [],
ivCheck: seed.ivCheck ?? false,
};
}
// Hysteria v1 defaults still emit `version: 2` to match the legacy panel
// constructor — the field discriminates v1 vs v2 inside the same settings
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
export interface HysteriaInboundSeed {
version?: number;
}
export function createDefaultHysteriaInboundSettings(
seed: HysteriaInboundSeed = {},
): HysteriaInboundSettings {
return {
version: seed.version ?? 2,
clients: [],
};
}
export function createDefaultHttpInboundSettings(): HttpInboundSettings {
return {
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
allowTransparent: false,
};
}
export function createDefaultMixedInboundSettings(): MixedInboundSettings {
return {
auth: 'password',
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
udp: false,
ip: '127.0.0.1',
};
}
function domainToHex(domain: string): string {
return Array.from(new TextEncoder().encode(domain))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// generateMtprotoSecret builds an "ee" FakeTLS secret: the marker, 16 random
// bytes (32 hex chars), then the domain encoded as hex. Mirrors the Go
// model.GenerateFakeTLSSecret; the backend re-derives it on save so this is
// only for immediate display in the form.
export function generateMtprotoSecret(domain: string): string {
return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`;
}
export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
return {
fakeTlsDomain: 'www.cloudflare.com',
clients: [],
};
}
// createDefaultMtprotoClient seeds a new MTProto client with a fresh FakeTLS
// secret fronting the given domain. Mirrors the WireGuard client default: the
// backend re-derives the secret on save, so this is only for immediate display.
export function createDefaultMtprotoClient(domain: string): Partial<MtprotoClient> {
return {
secret: generateMtprotoSecret(domain || 'www.cloudflare.com'),
};
}
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
return {
portMap: {},
allowedNetwork: 'tcp,udp',
followRedirect: false,
};
}
export function createDefaultTunInboundSettings(): TunInboundSettings {
return {
name: 'xray0',
mtu: 1500,
gateway: [],
dns: [],
userLevel: 0,
autoSystemRoutingTable: [],
autoOutboundsInterface: 'auto',
};
}
export interface WireguardInboundSeed {
mtu?: number;
secretKey?: string;
noKernelTun?: boolean;
}
// WireGuard is multi-client now: a new inbound holds only the server identity
// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
// through the client modal, which generates each one's keypair and a unique
// tunnel address. peers stays empty for backward-compatible parsing.
export function createDefaultWireguardInboundSettings(
seed: WireguardInboundSeed = {},
): WireguardInboundSettings {
return {
mtu: seed.mtu ?? 1420,
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
peers: [],
clients: [],
noKernelTun: seed.noKernelTun ?? false,
};
}
// AmneziaWG is multi-client, like WireGuard, and uses the same Curve25519
// keypair format — Wireguard.generateKeypair() works unchanged. Unlike
// WireGuard's Xray-native inbound, the server's publicKey is a real
// persisted field here (the Go backend reads it directly rather than
// re-deriving it), so it's seeded alongside privateKey. The obfuscation
// parameters (jc/jmin/.../i1) use the same starting values the Go backend's
// own generator range-checks against; the user (or the backend's own
// defaulting on save) can randomize/edit them further — see
// internal/amneziawg.GenerateObfuscation20 on the Go side.
export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSettings {
const kp = Wireguard.generateKeypair();
return {
server: {
privateKey: kp.privateKey,
publicKey: kp.publicKey,
subnetIp: '10.8.1.0',
subnetCidr: 24,
primaryDns: '8.8.8.8',
secondaryDns: '8.8.4.4',
externalInterface: '',
ipv6Enabled: false,
ipv6Subnet: '',
ipv6ExternalInterface: '',
routeThroughXray: false,
jc: 5,
jmin: 10,
jmax: 50,
s1: 30,
s2: 45,
s3: 10,
s4: 5,
h1: '',
h2: '',
h3: '',
h4: '',
i1: '',
},
clients: [],
};
}
// Protocol-aware dispatch over every inbound-settings factory. Mirrors
// the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but
// returns a plain Zod-parsable object instead of a class instance.
// Callers swapping off the class hierarchy use this in place of
// `getSettings(p)` + `.toJson()`.
export type AnyInboundSettings =
| VlessInboundSettings
| VmessInboundSettings
| TrojanInboundSettings
| ShadowsocksInboundSettings
| HysteriaInboundSettings
| HttpInboundSettings
| MixedInboundSettings
| TunInboundSettings
| TunnelInboundSettings
| WireguardInboundSettings
| MtprotoInboundSettings
| AmneziawgInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
case 'vless': return createDefaultVlessInboundSettings();
case 'vmess': return createDefaultVmessInboundSettings();
case 'trojan': return createDefaultTrojanInboundSettings();
case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
case 'hysteria': return createDefaultHysteriaInboundSettings();
case 'http': return createDefaultHttpInboundSettings();
case 'mixed': return createDefaultMixedInboundSettings();
case 'tunnel': return createDefaultTunnelInboundSettings();
case 'tun': return createDefaultTunInboundSettings();
case 'wireguard': return createDefaultWireguardInboundSettings();
case 'mtproto': return createDefaultMtprotoInboundSettings();
case 'amneziawg': return createDefaultAmneziawgInboundSettings();
default: return null;
}
}