mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-27 08:15:01 +03:00
0054e671f8
* feat(tuic): implement native in-process Go TUIC v5 server - Implement native TUIC v5 protocol server on pure Go using quic-go - Bridge decrypted TCP/UDP traffic into Xray-core via loopback SOCKS5 inbound - Support full Xray routing rules (geosite/geoip) and cascading outbounds - Implement atomic per-client traffic accounting with TotalGB and ExpiryTime - Add automatic legacy cleanup for older Rust tuic-server binaries, configs, and orphaned processes - Eliminate external Rust tuic-server downloads from install/CI scripts * fix(tuic): address traffic accounting, client reload, and socket lifecycle issues * fix(service): update checkTuicSocksReverseConflict to use bindAddr for listenOverlaps * fix(tuic): resolve traffic double-accounting, UDP fragmentation, and socket lifecycle issues * feat(tuic): complete native Go integration and address audit findings - Integrate an isolated QUIC fork pinned to a specific commit - Preserve original QUIC dependencies for Xray, Hysteria and Gin - Apply BBR, CUBIC and Reno to server connections and exported client profiles - Bridge Xray BBR with correct monotonic time and congestion type conversions - Handle congestion sender recreation after PMTU changes - Update congestion control for new connections without restarting the listener - Preserve existing connections and their selected congestion controller - Apply per-inbound log levels through the shared panel logger - Add lifecycle, authentication and TCP/UDP relay events without exposing secrets - Rate-limit repeated authentication and relay warnings - Support native and QUIC UDP relay modes on the same listener - Recover UDP associations after relay worker failures - Fix TCP relay cancellation, idle shutdown and half-close handling - Close active sessions when client credentials are revoked or disabled - Track traffic by immutable client statistics IDs across email and UUID changes - Prevent ambiguous accounting and duplicate UUIDs within TUIC inbounds - Persist pending traffic in a durable shutdown journal - Replay journal batches transactionally without duplicate accounting - Report server shutdown failures through the shared logger - Preserve legacy flat and nested TUIC settings compatibility - Normalize congestion controller values consistently across backend and frontend - Preserve controller, UDP mode and SNI in client links and subscriptions - Separate client profile options from server settings in the TUIC form - Keep certificate path autofill explicit when changing client SNI - Align UDP packet size validation with protocol limits - Simplify and localize TUIC field hints and certificate autofill messages - Add controller, TCP/UDP, logging and live settings update tests - Add accounting identity, journal replay and shutdown regression tests - Add relay recovery, session revocation and legacy frontend form tests * fix(service): alias the TUIC duplicate-UUID subquery for PostgreSQL < 16 syncInboundClients runs a COUNT(*) FROM (subquery) for every client sync, whatever the protocol. PostgreSQL before 16 rejects a FROM subquery with no alias, so on the distro PostgreSQL install.sh provisions (14 on Ubuntu 22.04, 15 on Debian 12) every client add or edit failed with SQLSTATE 42601. Reproduced against postgres:15 with the new env-gated test. * fix(database): create tuic_traffic_receipts through the model migration AddTuicTrafficBatch issued CREATE TABLE IF NOT EXISTS at runtime, a schema change outside db.go. The table was invisible to allModels and migrationModels, so x-ui migrate-db dropped the receipts and a retained journal could be counted twice after a SQLite to PostgreSQL move. It is now a GORM model in both lists, and the insert uses OnConflict DoNothing. * chore(tuic): skip the ICMP-dependent relay test on Windows, drop dead collectors Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails a read there and TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure was red on every Windows run. Server.CollectTotalTraffic and Manager.CollectTraffic had no caller. * refactor(tuic): serve TUIC on apernet/quic-go instead of a personal fork The native server depended on github.com/poise52/quic-go, a personal fork of apernet/quic-go patched only to pick the congestion controller before the handshake. That put a second QUIC/TLS stack in the binary that no upstream security fix reaches. apernet/quic-go is already in the graph through xray-core and exposes SetCongestionControl, so BBR is now installed on each accepted connection with Xray's own congestion.UseBBR; the cross-module BBR adapter is gone. apernet ships New Reno as its only built-in sender, so a cubic setting is served as new_reno server-side (clients still get cubic in their profile). The test inspectors now read the sender under congestionMutex, which the post-handshake install writes under. Linux loopback, single stream through Xray: 2428 -> 3383 Mbit/s (bbr). --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
import { resolveTuicServerSettings } from '@/lib/tuic';
|
|
import type {
|
|
InboundFormValues,
|
|
ShareAddrStrategy,
|
|
TrafficReset,
|
|
} from '@/schemas/forms/inbound-form';
|
|
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
|
import {
|
|
AmneziawgClientSchema,
|
|
HysteriaClientSchema,
|
|
MtprotoClientSchema,
|
|
ShadowsocksClientSchema,
|
|
TrojanClientSchema,
|
|
TuicClientSchema,
|
|
VlessClientSchema,
|
|
VmessClientSchema,
|
|
WireguardClientSchema,
|
|
} from '@/schemas/protocols/inbound';
|
|
import type { StreamSettings } from '@/schemas/api/inbound';
|
|
import type { Sniffing } from '@/schemas/primitives';
|
|
import type { z } from 'zod';
|
|
import {
|
|
dropEmptyFinalMask,
|
|
normalizeStreamSettingsForWire,
|
|
} from '@/lib/xray/stream-wire-normalize';
|
|
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
|
import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
|
|
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
|
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
|
|
|
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
|
|
|
// Plain-data adapter between the panel's stored inbound row shape and
|
|
// the typed InboundFormValues that Form.useForm<T> carries inside
|
|
// InboundFormModal. No dependency on the legacy Inbound/DBInbound
|
|
// classes — the modal hands the raw row in, takes typed values out, and
|
|
// on submit calls formValuesToWirePayload() to get a payload ready to
|
|
// POST to /panel/api/inbounds/add or /update/:id.
|
|
|
|
export interface RawInboundRow {
|
|
port?: number;
|
|
listen?: string;
|
|
protocol?: string;
|
|
tag?: string;
|
|
settings?: unknown;
|
|
streamSettings?: unknown;
|
|
sniffing?: unknown;
|
|
up?: number;
|
|
down?: number;
|
|
total?: number;
|
|
remark?: string;
|
|
enable?: boolean;
|
|
expiryTime?: number;
|
|
trafficReset?: string;
|
|
trafficResetDay?: number;
|
|
lastTrafficResetTime?: number;
|
|
nodeId?: number | null;
|
|
shareAddrStrategy?: string;
|
|
shareAddr?: string;
|
|
subSortIndex?: number;
|
|
excludeFromSub?: boolean;
|
|
disableFlow?: boolean;
|
|
clientStats?: unknown;
|
|
}
|
|
|
|
// The wire payload — settings/streamSettings/sniffing arrive as JSON
|
|
// strings, mirroring what the Go endpoints expect (xray-core wants the
|
|
// nested config slices as strings to round-trip through its loader).
|
|
export interface WireInboundPayload {
|
|
up: number;
|
|
down: number;
|
|
total: number;
|
|
remark: string;
|
|
enable: boolean;
|
|
expiryTime: number;
|
|
trafficReset: TrafficReset;
|
|
trafficResetDay: number;
|
|
lastTrafficResetTime: number;
|
|
listen: string;
|
|
port: number;
|
|
protocol: string;
|
|
settings: string;
|
|
streamSettings: string;
|
|
sniffing: string;
|
|
tag: string;
|
|
clientStats?: unknown;
|
|
nodeId?: number;
|
|
shareAddrStrategy: ShareAddrStrategy;
|
|
shareAddr: string;
|
|
subSortIndex: number;
|
|
excludeFromSub: boolean;
|
|
disableFlow: boolean;
|
|
}
|
|
|
|
function coerceJsonObject(value: unknown): Record<string, unknown> {
|
|
if (value == null) return {};
|
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>;
|
|
}
|
|
if (typeof value !== 'string') return {};
|
|
const trimmed = value.trim();
|
|
if (trimmed === '') return {};
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? (parsed as Record<string, unknown>)
|
|
: {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
|
const SHARE_ADDR_STRATEGIES: ShareAddrStrategy[] = ['node', 'listen', 'custom'];
|
|
|
|
function coerceTrafficReset(v: unknown): TrafficReset {
|
|
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
|
|
? (v as TrafficReset)
|
|
: 'never';
|
|
}
|
|
|
|
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
|
|
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
|
|
? (v as ShareAddrStrategy)
|
|
: 'node';
|
|
}
|
|
|
|
// Network values that map to a required `${network}Settings` key in
|
|
// NetworkSettingsSchema. Older saved inbounds may be missing the per-
|
|
// network sub-object (the legacy panel sometimes emitted streamSettings
|
|
// without it, and an earlier panel-side prune wrongly stripped empty
|
|
// `tcpSettings: {}` out of the wire payload). Reseat an empty object
|
|
// here so InboundFormSchema.safeParse doesn't blow up at edit time.
|
|
const NETWORK_SETTINGS_KEY: Record<string, string> = {
|
|
tcp: 'tcpSettings',
|
|
kcp: 'kcpSettings',
|
|
ws: 'wsSettings',
|
|
grpc: 'grpcSettings',
|
|
httpupgrade: 'httpupgradeSettings',
|
|
xhttp: 'xhttpSettings',
|
|
hysteria: 'hysteriaSettings',
|
|
};
|
|
|
|
function healStreamNetworkKey(stream: Record<string, unknown>): void {
|
|
if (typeof stream.method === 'string' && stream.method !== '') {
|
|
stream.network = stream.method;
|
|
}
|
|
delete stream.method;
|
|
const network = typeof stream.network === 'string' ? stream.network : '';
|
|
const key = NETWORK_SETTINGS_KEY[network];
|
|
if (!key) return;
|
|
if (stream[key] == null || typeof stream[key] !== 'object') {
|
|
stream[key] = {};
|
|
}
|
|
}
|
|
|
|
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
|
|
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
|
|
return Array.isArray(tls?.certificates) ? (tls.certificates as Record<string, unknown>[]) : [];
|
|
}
|
|
|
|
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
|
|
for (const c of tlsCerts(stream)) c.useFile = tlsCertUsesFiles(c);
|
|
}
|
|
|
|
function stripTlsCertUseFile(stream: Record<string, unknown>): void {
|
|
for (const c of tlsCerts(stream)) delete c.useFile;
|
|
}
|
|
|
|
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
|
const rawSettings = coerceJsonObject(row.settings);
|
|
const settings = (
|
|
protocol === 'tuic'
|
|
? { clients: rawSettings.clients, server: resolveTuicServerSettings(rawSettings) }
|
|
: rawSettings
|
|
) as InboundSettings['settings'];
|
|
const rawStream = coerceJsonObject(row.streamSettings);
|
|
const streamSettings =
|
|
Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
|
|
if (streamSettings) {
|
|
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
|
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
|
const streamRecord = streamSettings as unknown as Record<string, unknown>;
|
|
const xh = streamRecord.xhttpSettings;
|
|
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
|
|
const parsed = XHttpStreamSettingsSchema.safeParse(xh);
|
|
const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
|
|
streamRecord.xhttpSettings = xhttp;
|
|
const xmux = xhttp.xmux;
|
|
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
|
|
xhttp.enableXmux = true;
|
|
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
|
|
}
|
|
}
|
|
const so = streamRecord.sockopt;
|
|
if (so && typeof so === 'object' && !Array.isArray(so)) {
|
|
const raw = { ...(so as Record<string, unknown>) };
|
|
// Imported/API configs may use lowercase v6only; the form key is V6Only.
|
|
if ('v6only' in raw) {
|
|
if (!('V6Only' in raw)) raw.V6Only = Boolean(raw.v6only);
|
|
delete raw.v6only;
|
|
}
|
|
const parsed = SockoptStreamSettingsSchema.safeParse(raw);
|
|
if (parsed.success) {
|
|
streamRecord.sockopt = { ...raw, ...parsed.data };
|
|
} else {
|
|
streamRecord.sockopt = raw;
|
|
}
|
|
}
|
|
}
|
|
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
|
|
|
return {
|
|
remark: row.remark ?? '',
|
|
enable: row.enable ?? true,
|
|
port: row.port ?? 0,
|
|
listen: row.listen ?? '',
|
|
tag: row.tag ?? '',
|
|
expiryTime: row.expiryTime ?? 0,
|
|
sniffing,
|
|
streamSettings,
|
|
up: row.up ?? 0,
|
|
down: row.down ?? 0,
|
|
total: row.total ?? 0,
|
|
trafficReset: coerceTrafficReset(row.trafficReset),
|
|
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
|
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
|
nodeId: row.nodeId ?? null,
|
|
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
|
shareAddr: row.shareAddr ?? '',
|
|
subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
|
|
excludeFromSub: row.excludeFromSub ?? false,
|
|
disableFlow: row.disableFlow ?? false,
|
|
protocol,
|
|
settings,
|
|
} as InboundFormValues;
|
|
}
|
|
|
|
// Recursively strip undefined leaves from the wire payload. Empty arrays
|
|
// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
|
|
// shells like `tcpSettings: {}` so xray-core picks up its built-in
|
|
// defaults, and stripping them led the FE to lose required-but-empty
|
|
// arrays (vless clients, wireguard peers, etc.) which the Go side then
|
|
// serialized back as `null`. Primitive values (including 0, false, '')
|
|
// are kept verbatim.
|
|
export function pruneEmpty(value: unknown): unknown {
|
|
if (Array.isArray(value)) {
|
|
return value.map(pruneEmpty);
|
|
}
|
|
if (value !== null && typeof value === 'object') {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
const p = pruneEmpty(v);
|
|
if (p === undefined) continue;
|
|
out[k] = p;
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Per-protocol client field whitelist — the Zod schemas in
|
|
// schemas/protocols/inbound/<proto>.ts define which keys a given
|
|
// protocol's clients accept on the wire. When a global client is created
|
|
// the panel may persist cross-protocol fields on the same row (`auth` for
|
|
// hysteria, `password` for trojan, `security` for vmess, etc.); rendering
|
|
// those inside a vless inbound's settings.clients is confusing and rides
|
|
// dead weight in the wire payload. Parsing through the protocol's schema
|
|
// gives us the canonical projection.
|
|
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
|
switch (protocol) {
|
|
case 'vless':
|
|
return VlessClientSchema;
|
|
case 'vmess':
|
|
return VmessClientSchema;
|
|
case 'trojan':
|
|
return TrojanClientSchema;
|
|
case 'shadowsocks':
|
|
return ShadowsocksClientSchema;
|
|
case 'hysteria':
|
|
return HysteriaClientSchema;
|
|
case 'wireguard':
|
|
return WireguardClientSchema;
|
|
case 'mtproto':
|
|
return MtprotoClientSchema;
|
|
case 'amneziawg':
|
|
return AmneziawgClientSchema;
|
|
case 'tuic':
|
|
return TuicClientSchema;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function normalizeClients(protocol: string, clients: unknown): unknown {
|
|
const schema = clientSchemaForProtocol(protocol);
|
|
if (!schema || !Array.isArray(clients)) return clients;
|
|
return clients.map((c) => {
|
|
const parsed = schema.safeParse(c);
|
|
return parsed.success ? parsed.data : c;
|
|
});
|
|
}
|
|
|
|
// Sniffing normalizer matching the legacy Sniffing.toJson(): when
|
|
// disabled the payload is the bare `{ enabled: false }` regardless of
|
|
// what the form holds; when enabled, only non-default fields ride.
|
|
export function normalizeSniffing(s: Sniffing | undefined): Record<string, unknown> {
|
|
if (!s || !s.enabled) return { enabled: false };
|
|
const out: Record<string, unknown> = {
|
|
enabled: true,
|
|
destOverride: s.destOverride,
|
|
};
|
|
if (s.metadataOnly) out.metadataOnly = true;
|
|
if (s.routeOnly) out.routeOnly = true;
|
|
if (s.ipsExcluded?.length) out.ipsExcluded = s.ipsExcluded;
|
|
if (s.domainsExcluded?.length) out.domainsExcluded = s.domainsExcluded;
|
|
return out;
|
|
}
|
|
|
|
// Drops cosmetic empty-array keys that legacy XrayCommonClass.toJson()
|
|
// explicitly skipped (fallbacks/finalmask). Mutates the pruned settings
|
|
// objects in place; called AFTER pruneEmpty so we can lean on the
|
|
// already-shallow shape.
|
|
export function dropLegacyOptionalEmpties(
|
|
settings: Record<string, unknown>,
|
|
stream: Record<string, unknown> | undefined,
|
|
): void {
|
|
// VLESS/Trojan emit `fallbacks` only when non-empty.
|
|
const fb = settings.fallbacks;
|
|
if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
|
|
|
|
if (stream) {
|
|
dropEmptyFinalMask(stream);
|
|
|
|
// Hysteria's per-client auth lives in settings.clients[*].auth; the
|
|
// streamSettings.hysteriaSettings.auth slot is a holdover from older
|
|
// hysteria builds and serves no purpose on the inbound side, so an
|
|
// empty value shouldn't ride along in the JSON payload.
|
|
const hs = stream.hysteriaSettings as { auth?: string } | undefined;
|
|
if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
|
|
delete hs.auth;
|
|
}
|
|
}
|
|
}
|
|
|
|
// An existing inbound's clients change only through the client endpoints, so
|
|
// the edit form neither loads them nor sends them back.
|
|
export function withoutClients(values: InboundFormValues): InboundFormValues {
|
|
const settings = { ...(values.settings as Record<string, unknown> | undefined) };
|
|
delete settings.clients;
|
|
return { ...values, settings } as InboundFormValues;
|
|
}
|
|
|
|
export function formValuesToWirePayload(
|
|
values: InboundFormValues,
|
|
options: { omitClients?: boolean } = {},
|
|
): WireInboundPayload {
|
|
const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
|
|
if (options.omitClients) {
|
|
delete settingsPruned.clients;
|
|
} else if (Array.isArray(settingsPruned.clients)) {
|
|
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
|
}
|
|
let streamPruned = values.streamSettings
|
|
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
|
: undefined;
|
|
if (streamPruned) {
|
|
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
|
|
stripTlsCertUseFile(streamPruned);
|
|
}
|
|
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
|
const payload: WireInboundPayload = {
|
|
up: values.up,
|
|
down: values.down,
|
|
total: values.total,
|
|
remark: values.remark,
|
|
enable: values.enable,
|
|
expiryTime: values.expiryTime,
|
|
trafficReset: values.trafficReset,
|
|
trafficResetDay: values.trafficResetDay,
|
|
lastTrafficResetTime: values.lastTrafficResetTime,
|
|
listen: values.listen,
|
|
port: values.port,
|
|
protocol: values.protocol,
|
|
settings: JSON.stringify(settingsPruned),
|
|
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
|
|
// mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
|
|
// rather than the default { enabled: false } so the row carries no sniffing.
|
|
sniffing: canEnableSniffing({ protocol: values.protocol })
|
|
? JSON.stringify(normalizeSniffing(values.sniffing))
|
|
: '',
|
|
tag: values.tag,
|
|
shareAddrStrategy: values.shareAddrStrategy,
|
|
shareAddr: values.shareAddr,
|
|
subSortIndex: values.subSortIndex,
|
|
excludeFromSub: values.excludeFromSub,
|
|
disableFlow: values.disableFlow,
|
|
};
|
|
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
|
return payload;
|
|
}
|