Feature/tuic v5 (#6337)

* Feat(tuic): Implement native TUIC v5 protocol support via Rust sidecar daemon

- Add internal/tuic package for official tuic-server sidecar lifecycle management, configuration generation, and graceful process control
- Bridge decrypted TUIC QUIC traffic into loopback Xray SOCKS5 inbounds (63200+id) for traffic accounting, statistics, and routing rules
- Implement periodic reconciliation job (cadence @every 10s) and immediate runtime synchronization on inbound/client mutations
- Add TUIC inbound & multi-user client settings (UUID + Password authentication) in Web UI with SNI auto-fill and panel certificate loader
- Integrate tuic:// subscription links and Clash.Meta (Mihomo) proxy generation for TUIC
- Update install.sh to automatically download and install official tuic-server release for x86_64, aarch64, and armv7
- Add full localization for TUIC protocol across all 13 supported languages

* Feat(install): Support custom repository and branch in install and update scripts

* Ci(release): Enable publish-dev for feature branch and workflow dispatch

* Feat(sub): Add TUIC to subscription resolution and client QR config generator

- Add 'tuic' to getInboundsBySubId SQL allowlist to resolve TUIC inbounds in subscriptions and sub links
- Enhance buildTuicProxy in Clash subscription generator with robust host and credentials resolution
- Add tuicConfig.ts to generate standalone Clash/Mihomo YAML configuration
- Add dedicated TUIC Config tab in ClientQrModal with QR code and .yaml download button
- Add localization keys for TUIC config across all 13 supported languages

* Fix(tuic): Exclude TUIC from native Xray inbounds and strip udp_relay_mode from server config

- Exclude model.TUIC from native Xray inbounds in GetXrayConfig to prevent Xray startup failure
- Remove udp_relay_mode from tuic-server JSON configuration builder
- Update install.sh to install tuic-server binary to both xui_folder/bin and /usr/local/bin

* Fix(install): Fallback to dev-latest when releases/latest is not present on fork

* Feat(tuic): Add real-time online status and LastOnline tracking for TUIC clients

- Track client activity by mapping client UUID in tuic-server logs to email
- Integrate TUIC active clients into XrayTrafficJob to refresh local online clients
- Bump LastOnline timestamp in database and broadcast live online status over WebSocket

* Feat(tuic): Implement real-time traffic statistics and live speed reporting for TUIC

- Collect precise I/O traffic deltas for tuic-server child processes via /proc/<pid>/io
- Aggregate and attribute TUIC traffic deltas per client in tuic Manager
- Integrate TUIC traffic deltas into XrayTrafficJob to update database and broadcast live speed

* Feat(tuic): Finalize TUIC v5 integration with 1:1 traffic counting and orphan process cleanup
- Use exact 1:1 byte delta accounting from /proc/<pid>/io
- Add killStrayTuicProcesses to terminate orphan sidecars on panel startup
- Fully integrate TUIC with subscriptions, live speed meter, and all 13 locales

* Feat(frontend): Polish TUIC UI, support bulk operations, and update translations

- Align TUIC inbound certificate form with standard 3X-UI layout (Set Default Cert, Clear)
- Remove extra subtitle hint text from TUIC inbound form fields
- Support TUIC in client bulk attach/detach and bulk add modals
- Add TUIC badge color to client info modal, clients table, and host list
- Update password tooltip across all 13 locales to include TUIC
- Remove obsolete dead translation keys across all 13 locales

* Chore(ci): Finalize TUIC v5 bundling across release workflow, Docker, and scripts

* Feat(openapi): Update OpenAPI generator and schemas for TUIC types

* Fix(backend): Address core review findings for TUIC types, port checks, and xray bridge

* Refactor(traffic): Isolate proc reading with build tags and decouple TUIC metering into TuicJob

* Feat(client): Add TuicServer to InboundOption, fix config export and clean share links

* Fix(frontend): Register TUIC in multi-user helpers, tracked protocols, and tag derivation

* Chore(openapi): Re-generate OpenAPI specification and sync Zod schemas

* Chore(scripts): Add Alpine musl binaries, 386 and Windows packaging, and anchor pkill

* Fix(review): Remove stale import, correct binary names, switch to musl, and drop unreachable relay gate

* Feat(frontend): Show share link in Inbound Info and display UDP tag for TUIC

* Docs: Add TUIC v5 configuration guide and link specifications

* Docs(tuic): Correct Clash Meta configuration parameter to reduce-rtt

* Fix(tuic): Generate client credentials on copy, enforce ID/password validation, and add i386 to DockerInit

* Fix(tuic): drop unused relay, fix traffic accounting, and honor host endpoints

- Drop unused loopback SOCKS relay and eliminate port collision with AmneziaWG
- Correct inbound traffic calculation without double-counting
- Drop heuristic client traffic division while retaining online tracking
- Support externalProxy host fan-out and conditional parameters in share links
- Scope orphan process termination to managed config directory

* Fix(tuic): enforce client quotas, decouple Xray restart, and sync openapi schemas

- Regenerate OpenAPI, Zod schemas, and TypeScript types without route_through_xray
- Populate clientTraffics in TuicJob to enforce client quotas and first-use expiry
- Split process I/O delta into up and down in Process.CollectTraffic
- Remove SetNeedRestart from updateTuicInbound to prevent Xray session drops
- Use InstanceFromInbound for default ALPN and UDP relay mode in tuic:// share links
- Support allow_insecure on externalProxy host endpoints without parameter collision

* Fix(tuic): attribute client traffic only on single-user inbounds and sync link defaults

- Attribute I/O deltas to the client only when the inbound has exactly one configured client, avoiding false billing and disablings on multi-user inbounds
- Aggregate client traffic by email in TuicJob so clients on multiple inbounds don't lose deltas
- Match frontend genTuicLink defaults for alpn and udp_relay_mode with backend subscription links

* Fix(tuic): gate client traffic by total sidecar clients and require client email

* Fix(tuic): enforce inbound-only traffic limits and disable client totalGB

* fix(tuic): restore delayed start, remove client totalGB rejection, and document linux-only limits

* fix(tuic): anchor pkill, fix io baseline/split, escape yaml, and deduplicate start errors

* fix(tuic): prevent traffic double-counting, ensure info log level for delayed start, and broaden pkill matching

* fix(tuic): address review round 11 findings

- internal/sub/json_service: skip tuic protocol in json subscription to prevent direct routing leak
- internal/sub/clash_service: honor externalProxy/host row allowInsecure, sni, and alpn in buildTuicProxy
- internal/web/runtime: decouple tuic inbound add/delete from xray restart
- internal/tuic/config: restore user log-level options (warn, error) without forced info clamp
- frontend/src/lib/xray/inbound-link: fix duplicate remark suffix and apply externalProxy TLS overrides
- frontend/src/schemas/protocols/stream/external-proxy: propagate allowInsecure through host mapping
- tests: add coverage for json sub skip, clash proxy overrides, and link generation

* fix(tuic): meter inbound traffic through a UDP relay and bracket IPv6 binds

Review repairs on the TUIC v5 sidecar integration:

- Inbound traffic was read from the sidecar's /proc/<pid>/io rchar, but
  the kernel only counts read()/write() there and tuic-server moves its
  sockets with recvfrom/recvmmsg/sendmmsg/sendto, so an inbound's up/down
  stayed at 0 forever and inbound total limits never tripped (measured:
  12 MiB relayed, rchar delta 0). The panel now owns the inbound's public
  UDP port with a small relay and runs tuic-server behind it on a loopback
  port, counting up/down exactly on every OS. tuic-server therefore logs
  127.0.0.1 as every client's address; per-client attribution stays
  unsupported since QUIC is opaque.
- Instance.BindTo formatted an IPv6 listen address as ":::8443", which
  tuic-server rejects with "invalid socket address syntax", so an inbound
  listening on "::" or any IPv6 literal never started. It now uses
  net.JoinHostPort; IPv4 output is unchanged.
- The log level is passed to the sidecar as chosen. Online status,
  last-online and delayed start are read from its Info lines, so the Log
  Level field now says that Warn and Error switch them off for the
  inbound, and the docs say the same.
- Drop two frontend tests that only exercised a getter and a set lookup,
  and strip the trailing blank line that made gofumpt fail on two of the
  new Go test files.

* fix(tuic): harden tag updates, runtime routing, and relay stability

---------

Co-authored-by: poise52 <equipoise52@gmail.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
amae
2026-09-12 13:15:48 +05:00
committed by GitHub
parent 0a2cd789ba
commit 6d96accd63
100 changed files with 4139 additions and 52 deletions
+21
View File
@@ -731,6 +731,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true,
"tuicServer": null,
"wgDns": "",
"wgMtu": 0,
"wgPublicKey": ""
@@ -1002,6 +1003,26 @@ export const EXAMPLES: Record<string, unknown> = {
"Tag": "inbound-443",
"Up": 1048576
},
"TuicClientSettings": {
"email": "",
"password": "",
"uuid": ""
},
"TuicServerSettings": {
"alpn": [
""
],
"authentication_timeout": 0,
"certificate": "",
"congestion_control": "",
"log_level": "",
"max_idle_time": 0,
"max_udp_relay_packet_size": 0,
"private_key": "",
"sni": "",
"udp_relay_mode": "",
"zero_rtt_handshake": false
},
"User": {
"id": 0,
"password": "",
+82 -1
View File
@@ -2578,7 +2578,8 @@ export const SCHEMAS: Record<string, unknown> = {
"tunnel",
"tun",
"mtproto",
"amneziawg"
"amneziawg",
"tuic"
],
"example": "vless",
"type": "string"
@@ -2797,6 +2798,14 @@ export const SCHEMAS: Record<string, unknown> = {
"example": true,
"type": "boolean"
},
"tuicServer": {
"allOf": [
{
"$ref": "#/components/schemas/TuicServerSettings"
}
],
"nullable": true
},
"wgDns": {
"type": "string"
},
@@ -4050,6 +4059,78 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"TuicClientSettings": {
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"uuid": {
"type": "string"
}
},
"required": [
"email",
"password",
"uuid"
],
"type": "object"
},
"TuicServerSettings": {
"properties": {
"alpn": {
"items": {
"type": "string"
},
"type": "array"
},
"authentication_timeout": {
"type": "integer"
},
"certificate": {
"type": "string"
},
"congestion_control": {
"type": "string"
},
"log_level": {
"type": "string"
},
"max_idle_time": {
"type": "integer"
},
"max_udp_relay_packet_size": {
"type": "integer"
},
"private_key": {
"type": "string"
},
"sni": {
"type": "string"
},
"udp_relay_mode": {
"type": "string"
},
"zero_rtt_handshake": {
"type": "boolean"
}
},
"required": [
"alpn",
"authentication_timeout",
"certificate",
"congestion_control",
"log_level",
"max_idle_time",
"max_udp_relay_packet_size",
"private_key",
"udp_relay_mode",
"zero_rtt_handshake"
],
"type": "object"
},
"User": {
"description": "User represents a user account in the 3x-ui panel.",
"properties": {
+21
View File
@@ -642,6 +642,7 @@ export interface InboundOption {
ssMethod: string;
tag: string;
tlsFlowCapable: boolean;
tuicServer?: TuicServerSettings | null;
wgDns?: string;
wgMtu?: number;
wgPublicKey?: string;
@@ -921,6 +922,26 @@ export interface Traffic {
Up: number;
}
export interface TuicClientSettings {
email: string;
password: string;
uuid: string;
}
export interface TuicServerSettings {
alpn: string[];
authentication_timeout: number;
certificate: string;
congestion_control: string;
log_level: string;
max_idle_time: number;
max_udp_relay_packet_size: number;
private_key: string;
sni?: string;
udp_relay_mode: string;
zero_rtt_handshake: boolean;
}
export interface User {
id: number;
password: string;
+24 -1
View File
@@ -631,7 +631,7 @@ export const InboundSchema = z.object({
nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg']),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg', 'tuic']),
remark: z.string(),
settings: z.unknown(),
shareAddr: z.string(),
@@ -685,6 +685,7 @@ export const InboundOptionSchema = z.object({
ssMethod: z.string(),
tag: z.string(),
tlsFlowCapable: z.boolean(),
tuicServer: z.lazy(() => TuicServerSettingsSchema).nullable().optional(),
wgDns: z.string().optional(),
wgMtu: z.number().int().optional(),
wgPublicKey: z.string().optional(),
@@ -983,6 +984,28 @@ export const TrafficSchema = z.object({
});
export type Traffic = z.infer<typeof TrafficSchema>;
export const TuicClientSettingsSchema = z.object({
email: z.string(),
password: z.string(),
uuid: z.string(),
});
export type TuicClientSettings = z.infer<typeof TuicClientSettingsSchema>;
export const TuicServerSettingsSchema = z.object({
alpn: z.array(z.string()),
authentication_timeout: z.number().int(),
certificate: z.string(),
congestion_control: z.string(),
log_level: z.string(),
max_idle_time: z.number().int(),
max_udp_relay_packet_size: z.number().int(),
private_key: z.string(),
sni: z.string().optional(),
udp_relay_mode: z.string(),
zero_rtt_handshake: z.boolean(),
});
export type TuicServerSettings = z.infer<typeof TuicServerSettingsSchema>;
export const UserSchema = z.object({
id: z.number().int(),
password: z.string(),
+2
View File
@@ -20,6 +20,7 @@ export type HostLinkInput = Pick<
| 'overrideSniFromAddress'
| 'keepSniBlank'
| 'vlessRoute'
| 'allowInsecure'
>;
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
@@ -54,6 +55,7 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
echConfigList: host.echConfigList || undefined,
vlessRoute: host.vlessRoute || undefined,
allowInsecure: host.allowInsecure || undefined,
};
}
+40 -1
View File
@@ -11,6 +11,7 @@ import type {
ShadowsocksInboundSettings,
} from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TuicClient, TuicInboundSettings } from '@/schemas/protocols/inbound/tuic';
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';
@@ -137,6 +138,22 @@ export function createDefaultHysteriaClient(seed: HysteriaClientSeed = {}): Hyst
};
}
export interface TuicClientSeed extends ClientBaseSeed {
uuid?: string;
id?: string;
password?: string;
}
export function createDefaultTuicClient(seed: TuicClientSeed = {}): TuicClient {
const uuid = seed.uuid ?? seed.id ?? RandomUtil.randomUUID();
return {
uuid,
id: uuid,
password: seed.password ?? 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
@@ -321,6 +338,25 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
};
}
export function createDefaultTuicInboundSettings(): TuicInboundSettings {
return {
server: {
certificate: '',
private_key: '',
congestion_control: 'bbr',
alpn: ['h3', 'spdy/3.1'],
udp_relay_mode: 'native',
zero_rtt_handshake: true,
log_level: 'info',
max_idle_time: 15,
authentication_timeout: 3,
max_udp_relay_packet_size: 1500,
sni: '',
},
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.
@@ -338,7 +374,8 @@ export type AnyInboundSettings =
| TunnelInboundSettings
| WireguardInboundSettings
| MtprotoInboundSettings
| AmneziawgInboundSettings;
| AmneziawgInboundSettings
| TuicInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
@@ -366,6 +403,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
return createDefaultMtprotoInboundSettings();
case 'amneziawg':
return createDefaultAmneziawgInboundSettings();
case 'tuic':
return createDefaultTuicInboundSettings();
default:
return null;
}
@@ -10,6 +10,7 @@ import {
MtprotoClientSchema,
ShadowsocksClientSchema,
TrojanClientSchema,
TuicClientSchema,
VlessClientSchema,
VmessClientSchema,
WireguardClientSchema,
@@ -265,6 +266,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
return MtprotoClientSchema;
case 'amneziawg':
return AmneziawgClientSchema;
case 'tuic':
return TuicClientSchema;
default:
return null;
}
+77
View File
@@ -863,6 +863,70 @@ export function genMtprotoLink(input: GenMtprotoLinkInput): string {
return url.toString();
}
export interface GenTuicLinkInput {
inbound: Inbound;
address: string;
port?: number;
remark?: string;
clientUuid?: string;
clientPassword?: string;
externalProxy?: ExternalProxyEntry | null;
}
export function genTuicLink(input: GenTuicLinkInput): string {
const {
inbound,
address,
port = inbound.port,
remark = '',
clientUuid = '',
clientPassword = '',
externalProxy = null,
} = input;
if (!clientUuid || !clientPassword) return '';
const rawSettings = inbound.settings as Record<string, unknown>;
const server = (rawSettings.server as Record<string, unknown>) ?? rawSettings;
const host = formatUrlHost(externalProxy?.dest || address);
const targetPort = externalProxy?.port || port;
const url = new URL(
`tuic://${encodeURIComponent(clientUuid)}:${encodeURIComponent(clientPassword)}@${host}:${targetPort}`,
);
const cc =
(server.congestion_control as string) || (rawSettings.congestion_control as string) || 'bbr';
url.searchParams.set('congestion_control', cc);
const epAlpn = externalProxyAlpn(externalProxy?.alpn);
const alpn =
epAlpn ||
(Array.isArray(server.alpn) && server.alpn.length > 0
? (server.alpn as string[]).join(',')
: null) ||
(Array.isArray(rawSettings.alpn) && rawSettings.alpn.length > 0
? (rawSettings.alpn as string[]).join(',')
: null) ||
'h3,spdy/3.1';
url.searchParams.set('alpn', alpn);
const sni = externalProxy?.sni || (server.sni as string) || (rawSettings.sni as string);
if (sni) {
url.searchParams.set('sni', sni);
}
const udpRelay =
(server.udp_relay_mode as string) || (rawSettings.udp_relay_mode as string) || 'native';
url.searchParams.set('udp_relay_mode', udpRelay);
const allowInsecure = externalProxy?.allowInsecure ? '1' : '0';
url.searchParams.set('allow_insecure', allowInsecure);
if (remark) {
url.hash = encodeURIComponent(remark);
}
return url.toString();
}
export interface GenWireguardLinkInput {
settings: WireguardInboundSettings;
address: string;
@@ -1306,6 +1370,7 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
// clients, and any protocol without a clients array.
type ClientShape = {
id?: string;
uuid?: string;
security?: VmessSecurity;
flow?: VlessClient['flow'];
password?: string;
@@ -1333,6 +1398,8 @@ export function getInboundClients(inbound: Inbound): ClientShape[] | null {
return (inbound.settings.clients ?? []) as ClientShape[];
case 'mtproto':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'tuic':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'shadowsocks': {
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
@@ -1424,6 +1491,16 @@ export function genLink(input: GenLinkInput): string {
});
case 'mtproto':
return genMtprotoLink({ inbound, address, port, clientSecret: client.secret ?? '' });
case 'tuic':
return genTuicLink({
inbound,
address,
port,
remark,
clientUuid: client.uuid ?? client.id ?? '',
clientPassword: client.password ?? '',
externalProxy,
});
default:
return '';
}
+7 -1
View File
@@ -14,7 +14,13 @@ function inboundTransports(
streamSettings: Record<string, unknown> | undefined,
settings: Record<string, unknown> | undefined,
): TransportBits {
if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;
if (
protocol === 'hysteria' ||
protocol === 'wireguard' ||
protocol === 'amneziawg' ||
protocol === 'tuic'
)
return UDP;
let bits: TransportBits = 0;
const network = asString(streamSettings?.network);
+6
View File
@@ -27,6 +27,7 @@ const PROTOCOL_LABELS: Record<string, string> = {
wg: 'WireGuard',
tg: 'MTProto',
vpn: 'AmneziaWG',
tuic: 'TUIC',
};
const PROTOCOL_COLORS: Record<string, string> = {
@@ -39,6 +40,7 @@ const PROTOCOL_COLORS: Record<string, string> = {
WireGuard: 'cyan',
MTProto: 'blue',
AmneziaWG: 'yellow',
TUIC: 'orange',
};
const SECURITY_COLORS: Record<string, string> = {
@@ -129,6 +131,10 @@ export function parseLinkParts(link: string): LinkParts | null {
/* not URL-shaped, fall back to protocol only */
}
if (scheme === 'tg') security = 'FakeTLS';
if (scheme === 'tuic') {
network = 'quic';
security = 'TLS';
}
}
if (security === 'none') security = '';
return {
@@ -79,7 +79,9 @@ export function canEnableStream(values: { protocol: string }): boolean {
// Xray, so the Xray sniffing block does not apply to either. Every other
// inbound supports sniffing.
export function canEnableSniffing(values: { protocol: string }): boolean {
return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg';
return (
values.protocol !== 'mtproto' && values.protocol !== 'amneziawg' && values.protocol !== 'tuic'
);
}
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
+4
View File
@@ -177,6 +177,10 @@ export class DBInbound {
return this.protocol === Protocols.HYSTERIA;
}
get isTuic() {
return this.protocol === Protocols.TUIC;
}
get isTunnel() {
return this.protocol === Protocols.TUNNEL;
}
@@ -16,6 +16,7 @@ const MULTI_USER_PROTOCOLS = new Set([
'wireguard',
'mtproto',
'amneziawg',
'tuic',
]);
interface BulkAttachInboundsModalProps {
@@ -16,6 +16,7 @@ const MULTI_USER_PROTOCOLS = new Set([
'wireguard',
'mtproto',
'amneziawg',
'tuic',
]);
interface BulkDetachInboundsModalProps {
@@ -37,6 +37,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'hysteria',
'wireguard',
'amneziawg',
'tuic',
]);
const EMPTY: ClientBulkAddFormValues = {
@@ -127,6 +128,19 @@ export default function ClientBulkAddModal({
return '';
}, [inboundIds, inbounds]);
const tuicIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'tuic') ids.add(row.id);
}
return ids;
}, [inbounds]);
const hasTuic = useMemo(
() => (inboundIds || []).some((id) => tuicIds.has(id)),
[inboundIds, tuicIds],
);
useEffect(() => {
if (!showFlow && flow) {
methods.setValue('flow', '');
@@ -388,6 +402,9 @@ export default function ClientBulkAddModal({
<FormField
name="totalGB"
label={t('pages.clients.totalGB')}
tooltip={
hasTuic ? t('pages.clients.tuicTotalGBDesc') : t('pages.clients.totalGBDesc')
}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} step={1} />
+20 -1
View File
@@ -62,6 +62,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'wireguard',
'mtproto',
'amneziawg',
'tuic',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -446,6 +447,19 @@ export default function ClientFormModal({
return ids;
}, [inbounds]);
const tuicIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'tuic') ids.add(row.id);
}
return ids;
}, [inbounds]);
const hasTuic = useMemo(
() => (inboundIds || []).some((id) => tuicIds.has(id)),
[inboundIds, tuicIds],
);
const mtprotoDomain = useMemo(() => {
for (const id of inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
@@ -668,6 +682,7 @@ export default function ClientFormModal({
email: values.email.trim(),
subId: values.subId,
id: values.uuid,
uuid: values.uuid,
password: values.password,
auth: values.auth,
flow: showFlow ? values.flow || '' : '',
@@ -857,7 +872,11 @@ export default function ClientFormModal({
<FormField
name="totalGB"
label={t('pages.clients.totalGB')}
tooltip={t('pages.clients.totalGBDesc')}
tooltip={
hasTuic
? t('pages.clients.tuicTotalGBDesc')
: t('pages.clients.totalGBDesc')
}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
@@ -44,6 +44,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
tuic: 'orange',
};
const INBOUND_CHIP_LIMIT = 1;
+36 -2
View File
@@ -17,6 +17,7 @@ import {
findAmneziaWGInbounds,
isAmneziaWGClient,
} from './amneziawgConfig';
import { buildTuicClientConfig, findTuicInbound, isTuicClient } from './tuicConfig';
interface SubSettings {
enable: boolean;
@@ -110,8 +111,24 @@ export default function ClientQrModal({
.filter((c) => !!c.text);
}, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
const tuicInbound = useMemo(() => findTuicInbound(client, inboundsById), [client, inboundsById]);
const tuicConfigText = useMemo(() => {
if (!client || !tuicInbound || !isTuicClient(client)) return '';
return buildTuicClientConfig(
client,
tuicInbound,
window.location.hostname,
subSettings?.publicHost ?? '',
);
}, [client, tuicInbound, subSettings?.publicHost]);
const hasAnything =
!!subLink || !!subJsonLink || wgConfigs.length > 0 || awgConfigs.length > 0 || links.length > 0;
!!subLink ||
!!subJsonLink ||
wgConfigs.length > 0 ||
awgConfigs.length > 0 ||
!!tuicConfigText ||
links.length > 0;
// The reset runs during render so the effect only carries the request.
const openSubId = open ? (client?.subId ?? '') : '';
@@ -217,8 +234,25 @@ export default function ClientQrModal({
children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
});
});
if (tuicConfigText) {
out.push({
key: 'tuic-config',
label: (
<Tag color="orange" style={{ margin: 0 }}>
{t('pages.clients.tuicConfig')}
</Tag>
),
children: (
<QrPanel
value={tuicConfigText}
remark={client?.email || 'tuic'}
downloadName={`${client?.email || 'tuic'}.yaml`}
/>
),
});
}
return out;
}, [subLink, subJsonLink, wgConfigs, awgConfigs, links, client?.email, t]);
}, [subLink, subJsonLink, wgConfigs, awgConfigs, tuicConfigText, links, client?.email, t]);
// Expanding the first panel is a render-time adjustment, not a side effect.
const firstKey = open && items.length > 0 ? items[0].key : null;
@@ -177,6 +177,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
tuic: 'orange',
};
const INBOUND_CHIP_LIMIT = 1;
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
+64
View File
@@ -0,0 +1,64 @@
import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
export function isTuicClient(client: ClientRecord | null | undefined): boolean {
if (!client) return false;
return !!(client.uuid && client.password);
}
export function findTuicInbound(
client: ClientRecord | null | undefined,
inboundsById: Record<number, InboundOption>,
): InboundOption | undefined {
return (client?.inboundIds || [])
.map((id) => inboundsById[id])
.find((ib) => ib?.protocol === 'tuic');
}
export function buildTuicClientConfig(
client: ClientRecord,
inbound: InboundOption | undefined,
host = window.location.hostname,
publicHost = '',
): string {
const endpointHost = resolveShareHost(
inbound ?? {},
inbound?.nodeAddress ?? '',
preferPublicHost(host, publicHost),
);
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
const remark = [inboundName, client.email].filter(Boolean).join(' - ') || 'tuic-client';
const tuicServer = inbound?.tuicServer;
const alpn =
Array.isArray(tuicServer?.alpn) && tuicServer.alpn.length > 0
? tuicServer.alpn
: ['h3', 'spdy/3.1'];
const sni = tuicServer?.sni || endpointHost;
const cc = tuicServer?.congestion_control || 'bbr';
const udpRelay = tuicServer?.udp_relay_mode || 'native';
const reduceRtt = tuicServer?.zero_rtt_handshake ?? true;
const yamlQuote = (v: string) => JSON.stringify(v ?? '');
const lines = [
`# TUIC v5 Client Configuration (Clash / Mihomo / Clash Verge)`,
`# ${remark}`,
`proxies:`,
` - name: ${yamlQuote(remark)}`,
` type: tuic`,
` server: ${endpointHost}`,
` port: ${inbound?.port || 8443}`,
` uuid: ${client.uuid || ''}`,
` password: ${yamlQuote(client.password || '')}`,
` alpn:`,
...alpn.map((a: string) => ` - ${a}`),
` sni: ${/["'\\#\s]/.test(sni) ? yamlQuote(sni) : sni}`,
` congestion-controller: ${cc}`,
` udp-relay-mode: ${udpRelay}`,
` reduce-rtt: ${reduceRtt}`,
];
return lines.join('\n');
}
+1
View File
@@ -43,6 +43,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
tuic: 'orange',
};
export function sortHosts(hosts: HostRecord[]): HostRecord[] {
@@ -63,6 +63,7 @@ import {
MixedFields,
MtprotoFields,
ShadowsocksFields,
TuicFields,
TunFields,
TunnelFields,
VlessFields,
@@ -277,7 +278,8 @@ export default function InboundFormModal({
const hasSelectableTransport =
protocol !== Protocols.HYSTERIA &&
protocol !== Protocols.WIREGUARD &&
protocol !== Protocols.TUNNEL;
protocol !== Protocols.TUNNEL &&
protocol !== Protocols.TUIC;
const wPort = useWatch({ control, name: 'port' });
const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
@@ -797,6 +799,8 @@ export default function InboundFormModal({
/>
)}
{protocol === Protocols.TUIC && <TuicFields />}
{protocol === Protocols.TUN && <TunFields />}
{protocol === Protocols.TUNNEL && <TunnelFields />}
@@ -1135,6 +1139,7 @@ export default function InboundFormModal({
Protocols.WIREGUARD,
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
Protocols.TUIC,
] as string[]
).includes(protocol) || isFallbackHost
? [
@@ -8,3 +8,4 @@ export { default as MixedFields } from './mixed';
export { default as MtprotoFields } from './mtproto';
export { default as VlessFields } from './vless';
export { default as AmneziawgFields } from './amneziawg';
export { default as TuicFields } from './tuic';
@@ -0,0 +1,252 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AutoComplete,
Button,
Collapse,
Form,
Input,
InputNumber,
Select,
Space,
Switch,
message,
} from 'antd';
import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { HttpUtil } from '@/utils';
export default function TuicFields() {
const { t } = useTranslation();
const { control, setValue, getValues } = useFormContext();
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
const sni = (useWatch({ control, name: 'settings.server.sni' }) ?? '') as string;
const certificate = (useWatch({ control, name: 'settings.server.certificate' }) ?? '') as string;
const privateKey = (useWatch({ control, name: 'settings.server.private_key' }) ?? '') as string;
const nodeId = useWatch({ control, name: 'nodeId' }) as number | null | undefined;
const handleSniChange = (newSni: string) => {
setValue('settings.server.sni', newSni);
const cleanSni = newSni.trim();
if (!cleanSni) return;
const currentCert = String(getValues('settings.server.certificate') || '');
const currentKey = String(getValues('settings.server.private_key') || '');
if (!currentCert || currentCert.startsWith('/root/cert/')) {
setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
}
if (!currentKey || currentKey.startsWith('/root/cert/')) {
setValue('settings.server.private_key', `/root/cert/${cleanSni}/privkey.pem`);
}
};
const autofillFromSni = () => {
const cleanSni = (sni || '').trim();
if (!cleanSni) {
message.warning(t('pages.xray.tuic.sniHint'));
return;
}
setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
setValue('settings.server.private_key', `/root/cert/${cleanSni}/privkey.pem`);
};
const setCertFromPanel = async () => {
setLoadingPanelCert(true);
try {
const msg =
typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) {
message.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
return;
}
const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
if (!obj?.webCertFile && !obj?.webKeyFile) {
message.warning(t('pages.inbounds.setDefaultCertEmpty'));
return;
}
if (obj.webCertFile) {
setValue('settings.server.certificate', obj.webCertFile);
}
if (obj.webKeyFile) {
setValue('settings.server.private_key', obj.webKeyFile);
}
message.success(t('pages.inbounds.setSuccess'));
} catch {
message.error(t('somethingWentWrong'));
} finally {
setLoadingPanelCert(false);
}
};
const certOptions = sni
? [
{ value: `/root/cert/${sni}/fullchain.pem` },
{ value: `/etc/letsencrypt/live/${sni}/fullchain.pem` },
{ value: '/root/cert.pem' },
]
: [{ value: '/root/cert.pem' }];
const keyOptions = sni
? [
{ value: `/root/cert/${sni}/privkey.pem` },
{ value: `/etc/letsencrypt/live/${sni}/privkey.pem` },
{ value: '/root/privkey.pem' },
]
: [{ value: '/root/privkey.pem' }];
const advancedItems = [
{
key: 'advanced',
label: t('pages.inbounds.advancedTitle'),
children: (
<>
<FormField
name={['settings', 'server', 'zero_rtt_handshake']}
label={t('pages.xray.tuic.zeroRttHandshake')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['settings', 'server', 'log_level']}
label={t('pages.xray.tuic.logLevel')}
tooltip={t('pages.xray.tuic.logLevelHint')}
>
<Select
options={[
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
{ label: 'Debug', value: 'debug' },
]}
/>
</FormField>
<FormField
name={['settings', 'server', 'max_idle_time']}
label={t('pages.xray.tuic.maxIdleTime')}
>
<InputNumber min={1} style={{ width: '100%' }} />
</FormField>
<FormField
name={['settings', 'server', 'authentication_timeout']}
label={t('pages.xray.tuic.authTimeout')}
>
<InputNumber min={1} style={{ width: '100%' }} />
</FormField>
<FormField
name={['settings', 'server', 'max_udp_relay_packet_size']}
label={t('pages.xray.tuic.maxUdpRelayPacketSize')}
>
<InputNumber min={1} style={{ width: '100%' }} />
</FormField>
</>
),
},
];
return (
<>
<Form.Item label={t('pages.xray.tuic.sni')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={sni}
placeholder="example.com"
onChange={(e) => handleSniChange(e.target.value)}
style={{ flex: 1 }}
/>
<Button icon={<SyncOutlined />} onClick={autofillFromSni}>
{t('pages.inbounds.form.autoFill')}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')}>
<AutoComplete
value={certificate}
options={certOptions}
onChange={(v) => setValue('settings.server.certificate', v)}
placeholder="/root/cert.pem"
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.privatekey')}>
<AutoComplete
value={privateKey}
options={keyOptions}
onChange={(v) => setValue('settings.server.private_key', v)}
placeholder="/root/privkey.pem"
/>
</Form.Item>
<Form.Item label=" ">
<Space>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
loading={loadingPanelCert}
onClick={setCertFromPanel}
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button
danger
onClick={() => {
setValue('settings.server.certificate', '');
setValue('settings.server.private_key', '');
}}
>
{t('clear')}
</Button>
</Space>
</Form.Item>
<FormField
name={['settings', 'server', 'congestion_control']}
label={t('pages.xray.tuic.congestionControl')}
>
<Select
options={[
{ label: 'BBR', value: 'bbr' },
{ label: 'CUBIC', value: 'cubic' },
{ label: 'New Reno', value: 'new_reno' },
]}
/>
</FormField>
<FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
<Select
mode="tags"
tokenSeparators={[',']}
options={[
{ label: 'h3', value: 'h3' },
{ label: 'spdy/3.1', value: 'spdy/3.1' },
]}
/>
</FormField>
<FormField
name={['settings', 'server', 'udp_relay_mode']}
label={t('pages.xray.tuic.udpRelayMode')}
>
<Select
options={[
{ label: 'Native (Recommended)', value: 'native' },
{ label: 'QUIC', value: 'quic' },
]}
/>
</FormField>
<Collapse style={{ marginTop: 16, marginBottom: 8 }} items={advancedItems} />
</>
);
}
@@ -17,6 +17,7 @@ const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
Protocols.MTPROTO,
Protocols.TUIC,
]);
export function hasShareLink(protocol: string): boolean {
@@ -90,6 +90,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'mtproto':
case 'wireguard':
case 'amneziawg':
case 'tuic':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
@@ -16,6 +16,7 @@ export type ProtocolFlags = {
isHTTP?: boolean;
isWireguard?: boolean;
isAmneziawg?: boolean;
isTuic?: boolean;
isTunnel?: boolean;
};
@@ -199,7 +199,7 @@ export function useInboundColumns({
{record.protocol}
</Tag>,
];
if (record.isWireguard || record.isAmneziawg || record.isHysteria) {
if (record.isWireguard || record.isAmneziawg || record.isHysteria || record.isTuic) {
tags.push(
<Tag key="n" color="green">
UDP
@@ -67,6 +67,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
Protocols.WIREGUARD,
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
Protocols.TUIC,
];
async function fetchSlimInbounds(): Promise<unknown[]> {
+18
View File
@@ -101,6 +101,23 @@ export const AwgServerOptionSchema = z
})
.loose();
export const TuicServerOptionSchema = z
.object({
certificate: z.string().optional(),
congestion_control: z.string().optional(),
alpn: z.array(z.string()).optional(),
udp_relay_mode: z.string().optional(),
zero_rtt_handshake: z.boolean().optional(),
log_level: z.string().optional(),
max_idle_time: z.number().optional(),
authentication_timeout: z.number().optional(),
max_udp_relay_packet_size: z.number().optional(),
sni: z.string().optional(),
})
.loose();
export type TuicServerOption = z.infer<typeof TuicServerOptionSchema>;
export const InboundOptionSchema = z
.object({
id: z.number(),
@@ -116,6 +133,7 @@ export const InboundOptionSchema = z
wgMtu: z.number().optional(),
wgDns: z.string().optional(),
awgServer: AwgServerOptionSchema.nullable().optional(),
tuicServer: TuicServerOptionSchema.nullable().optional(),
mtprotoDomain: z.string().optional(),
// Hosting node id; absent/null for this panel's own inbounds (#4997).
nodeId: z.number().nullable().optional(),
@@ -13,6 +13,7 @@ export const ProtocolSchema = z.enum([
'tun',
'mtproto',
'amneziawg',
'tuic',
]);
export type Protocol = z.infer<typeof ProtocolSchema>;
@@ -35,4 +36,5 @@ export const Protocols = Object.freeze({
TUN: 'tun',
MTPROTO: 'mtproto',
AMNEZIAWG: 'amneziawg',
TUIC: 'tuic',
});
@@ -7,6 +7,7 @@ import { MixedInboundSettingsSchema } from './mixed';
import { MtprotoInboundSettingsSchema } from './mtproto';
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
import { TrojanInboundSettingsSchema } from './trojan';
import { TuicInboundSettingsSchema } from './tuic';
import { TunInboundSettingsSchema } from './tun';
import { TunnelInboundSettingsSchema } from './tunnel';
import { VlessInboundSettingsSchema } from './vless';
@@ -20,6 +21,7 @@ export * from './mixed';
export * from './mtproto';
export * from './shadowsocks';
export * from './trojan';
export * from './tuic';
export * from './tun';
export * from './tunnel';
export * from './vless';
@@ -44,5 +46,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
z.object({ protocol: z.literal('tuic'), settings: TuicInboundSettingsSchema }),
]);
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
@@ -0,0 +1,60 @@
import { z } from 'zod';
const optionalClearedInt = (schema: z.ZodNumber) =>
z.preprocess((v) => (v == null ? undefined : v), schema.optional());
const clearedToDefault = <T extends z.ZodType>(schema: T) =>
z.preprocess((v) => (v == null ? undefined : v), schema);
export const TuicClientSchema = z.object({
uuid: z.string().optional(),
id: z.string().optional(),
password: z.string().default(''),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
totalGB: z.number().int().min(0).default(0),
expiryTime: z.number().int().default(0),
enable: z.boolean().default(true),
tgId: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.default(0),
subId: z.string().default(''),
comment: z.string().default(''),
reset: z.number().int().min(0).default(0),
created_at: z.number().int().optional(),
updated_at: z.number().int().optional(),
});
export type TuicClient = z.infer<typeof TuicClientSchema>;
export const TuicServerSchema = z.object({
certificate: z.string().default(''),
private_key: z.string().default(''),
congestion_control: z.enum(['bbr', 'cubic', 'new_reno']).default('bbr'),
alpn: z.array(z.string()).default(['h3', 'spdy/3.1']),
udp_relay_mode: z.enum(['native', 'quic']).default('native'),
zero_rtt_handshake: z.boolean().default(true),
log_level: z.enum(['info', 'warn', 'error', 'debug']).default('info'),
max_idle_time: clearedToDefault(z.number().int().min(1).default(15)),
authentication_timeout: clearedToDefault(z.number().int().min(1).default(3)),
max_udp_relay_packet_size: clearedToDefault(z.number().int().min(1).default(1500)),
sni: z.string().default(''),
});
export type TuicServer = z.infer<typeof TuicServerSchema>;
export const TuicInboundSettingsSchema = z.object({
server: TuicServerSchema.optional(),
certificate: z.string().optional(),
private_key: z.string().optional(),
congestion_control: z.string().optional(),
alpn: z.array(z.string()).optional(),
udp_relay_mode: z.string().optional(),
zero_rtt_handshake: z.boolean().optional(),
log_level: z.string().optional(),
max_idle_time: optionalClearedInt(z.number().int().min(1)),
authentication_timeout: optionalClearedInt(z.number().int().min(1)),
max_udp_relay_packet_size: optionalClearedInt(z.number().int().min(1)),
sni: z.string().optional(),
clients: z.array(TuicClientSchema).default([]),
});
export type TuicInboundSettings = z.infer<typeof TuicInboundSettingsSchema>;
@@ -26,5 +26,6 @@ export const ExternalProxyEntrySchema = z.object({
verifyPeerCertByName: z.string().optional(),
echConfigList: z.string().optional(),
vlessRoute: z.string().optional(),
allowInsecure: z.boolean().optional(),
});
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
+1 -1
View File
@@ -52,7 +52,7 @@ describe('ClientFormModal credential tooltips', () => {
await waitFor(() => {
expect(document.body.textContent).toContain(
'Only used by Trojan and Shadowsocks clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
'Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
);
});
});
+8
View File
@@ -19,6 +19,7 @@ describe('hostToExternalProxyEntry', () => {
overrideSniFromAddress: false,
keepSniBlank: false,
vlessRoute: '',
allowInsecure: false,
};
it('maps the overlapping fields onto an external-proxy entry', () => {
@@ -60,6 +61,13 @@ describe('hostToExternalProxyEntry', () => {
expect(hostToExternalProxyEntry({ ...base, vlessRoute: '443' }).vlessRoute).toBe('443');
expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
});
it('carries allowInsecure through to the entry', () => {
expect(hostToExternalProxyEntry({ ...base, allowInsecure: true }).allowInsecure).toBe(true);
expect(
hostToExternalProxyEntry({ ...base, allowInsecure: false }).allowInsecure,
).toBeUndefined();
});
});
describe('withMtprotoHostEndpoints', () => {
@@ -9,6 +9,8 @@ import {
createDefaultShadowsocksInboundSettings,
createDefaultTrojanClient,
createDefaultTrojanInboundSettings,
createDefaultTuicClient,
createDefaultTuicInboundSettings,
createDefaultTunnelInboundSettings,
createDefaultVlessClient,
createDefaultVlessInboundSettings,
@@ -31,6 +33,7 @@ import {
TrojanClientSchema,
TrojanInboundSettingsSchema,
} from '@/schemas/protocols/inbound/trojan';
import { TuicClientSchema, TuicInboundSettingsSchema } from '@/schemas/protocols/inbound/tuic';
import { TunnelInboundSettingsSchema } from '@/schemas/protocols/inbound/tunnel';
import { VlessClientSchema, VlessInboundSettingsSchema } from '@/schemas/protocols/inbound/vless';
import { VmessClientSchema, VmessInboundSettingsSchema } from '@/schemas/protocols/inbound/vmess';
@@ -89,6 +92,17 @@ describe('createDefaultHysteriaClient', () => {
});
});
describe('createDefaultTuicClient', () => {
it('produces a Zod-valid client', () => {
const c = createDefaultTuicClient({
...seed,
uuid: '11111111-2222-3333-4444-555555555555',
password: 'fixed-tuic-pw',
});
expect(TuicClientSchema.parse(c)).toEqual(c);
});
});
describe('createDefault*InboundSettings factories', () => {
it('vless', () => {
const s = createDefaultVlessInboundSettings();
@@ -157,6 +171,11 @@ describe('createDefault*InboundSettings factories', () => {
expect(s.peers).toEqual([]);
expect(s.clients).toEqual([]);
});
it('tuic', () => {
const s = createDefaultTuicInboundSettings();
expect(TuicInboundSettingsSchema.parse(s)).toEqual(s);
});
});
describe('createHysteriaTlsSettingsWithDefaultCert', () => {
+152
View File
@@ -5,10 +5,12 @@ import {
amneziawgConfigFromLink,
genAmneziaWGConfig,
genAmneziaWGLink,
genAllLinks,
genHysteriaLink,
genInboundLinks,
genShadowsocksLink,
genTrojanLink,
genTuicLink,
applyVlessRoute,
genVlessLink,
genVmessLink,
@@ -1087,3 +1089,153 @@ describe('genVlessLink XHTTP extra compatibility', () => {
expect(extra.sessionKey).toBe('X-Session');
});
});
describe('genTuicLink', () => {
it('builds a standard tuic share link with all parameters', () => {
const inbound = InboundSchema.parse({
id: 1,
tag: 'tuic-test',
protocol: 'tuic',
port: 8443,
listen: '0.0.0.0',
enable: true,
settings: {
server: {
certificate: '/etc/cert.pem',
private_key: '/etc/key.pem',
congestion_control: 'bbr',
alpn: ['h3', 'spdy/3.1'],
udp_relay_mode: 'native',
zero_rtt_handshake: true,
sni: 'tuic.example.com',
},
clients: [
{
uuid: '11111111-2222-3333-4444-555555555555',
password: 'secretpassword',
email: 'user@tuic',
enable: true,
},
],
},
});
const link = genTuicLink({
inbound,
address: 'example.com',
port: 8443,
remark: 'TUIC-Node',
clientUuid: '11111111-2222-3333-4444-555555555555',
clientPassword: 'secretpassword',
});
expect(link).toContain(
'tuic://11111111-2222-3333-4444-555555555555:secretpassword@example.com:8443',
);
expect(link).toContain('congestion_control=bbr');
expect(link).toContain('alpn=h3%2Cspdy%2F3.1');
expect(link).toContain('sni=tuic.example.com');
expect(link).toContain('udp_relay_mode=native');
expect(link).toContain('allow_insecure=0');
expect(link).toContain('#TUIC-Node');
});
it('falls back to default alpn and udp_relay_mode when server settings are empty', () => {
const inbound = InboundSchema.parse({
id: 2,
tag: 'tuic-default-test',
protocol: 'tuic',
port: 8443,
listen: '0.0.0.0',
enable: true,
settings: {
clients: [
{
uuid: '11111111-2222-3333-4444-555555555555',
password: 'secretpassword',
email: 'user@tuic',
enable: true,
},
],
},
});
const link = genTuicLink({
inbound,
address: 'example.com',
port: 8443,
remark: 'TUIC-Default',
clientUuid: '11111111-2222-3333-4444-555555555555',
clientPassword: 'secretpassword',
});
expect(link).toContain('congestion_control=bbr');
expect(link).toContain('alpn=h3%2Cspdy%2F3.1');
expect(link).toContain('udp_relay_mode=native');
expect(link).toContain('allow_insecure=0');
});
it('applies externalProxy overrides (sni, alpn, allow_insecure) and does not duplicate remark', () => {
const inbound = InboundSchema.parse({
id: 3,
tag: 'tuic-ep-test',
protocol: 'tuic',
port: 8443,
listen: '0.0.0.0',
enable: true,
settings: {
server: {
certificate: '/etc/cert.pem',
private_key: '/etc/key.pem',
congestion_control: 'bbr',
alpn: ['h3'],
sni: 'default.example.com',
},
clients: [
{
uuid: '11111111-2222-3333-4444-555555555555',
password: 'secretpassword',
email: 'user@tuic',
enable: true,
},
],
},
streamSettings: {
externalProxy: [
{
dest: 'host-us.example.com',
port: 9443,
remark: 'US',
sni: 'override.example.com',
alpn: ['h3', 'h2'],
allowInsecure: true,
},
],
},
});
const entries = genAllLinks({
inbound,
remark: 'TUIC-Node',
client: {
uuid: '11111111-2222-3333-4444-555555555555',
password: 'secretpassword',
email: 'user@tuic',
},
fallbackHostname: 'panel.example.com',
});
expect(entries).toHaveLength(1);
expect(entries[0].remark).toBe('TUIC-Node-US');
const link = entries[0].link;
expect(link).toContain(
'tuic://11111111-2222-3333-4444-555555555555:secretpassword@host-us.example.com:9443',
);
expect(link).toContain('sni=override.example.com');
expect(link).toContain('alpn=h3%2Ch2');
expect(link).toContain('allow_insecure=1');
expect(link).toContain('#TUIC-Node-US');
expect(link).not.toContain('#TUIC-Node-US-US');
});
});
+5
View File
@@ -31,6 +31,11 @@ describe('composeInboundTag transport suffix parity', () => {
'in-443-udp',
],
['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'],
[
'tuic forced udp',
base({ protocol: 'tuic', streamSettings: { network: 'tcp' } }),
'in-443-udp',
],
[
'shadowsocks tcp,udp',
base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }),
+10
View File
@@ -86,4 +86,14 @@ describe('link-label parseLinkParts', () => {
expect(parts?.port).toBe('36541');
expect(parts && linkMetaText(parts)).toBe('wg-Майфун:36541');
});
it('labels a tuic link with QUIC network and TLS security', () => {
const link = 'tuic://uuid:pass@tuic.example.com:8443?congestion_control=bbr#tuic-remark';
const parts = parseLinkParts(link);
expect(parts?.protocol).toBe('TUIC');
expect(parts?.network).toBe('QUIC');
expect(parts?.security).toBe('TLS');
expect(parts?.port).toBe('8443');
expect(parts?.remark).toBe('tuic-remark');
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { buildTuicClientConfig } from '@/pages/clients/tuicConfig';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
const client: ClientRecord = {
id: 1,
email: 'tuic-user@example.com',
uuid: 'e79b9107-1607-4e6c-a496-d8f99e4f0dc5',
password: 'testpassword123',
inboundIds: [10],
};
const inbound: InboundOption = {
id: 10,
tag: 'in-8443-udp',
remark: 'TUIC Main',
protocol: 'tuic',
port: 8443,
tuicServer: {
sni: 'vpn.example.com',
congestion_control: 'cubic',
alpn: ['h3', 'spdy/3.1'],
udp_relay_mode: 'native',
zero_rtt_handshake: true,
},
};
describe('buildTuicClientConfig', () => {
it('builds valid YAML proxy entry from tuicServer option', () => {
const cfg = buildTuicClientConfig(client, inbound, 'server.example.com', '');
expect(cfg).toContain('type: tuic');
expect(cfg).toContain('server: server.example.com');
expect(cfg).toContain('port: 8443');
expect(cfg).toContain('uuid: e79b9107-1607-4e6c-a496-d8f99e4f0dc5');
expect(cfg).toContain('password: "testpassword123"');
expect(cfg).toContain('sni: vpn.example.com');
expect(cfg).toContain('congestion-controller: cubic');
expect(cfg).toContain('udp-relay-mode: native');
expect(cfg).toContain('reduce-rtt: true');
});
it('falls back to endpointHost when sni is empty', () => {
const inboundNoSni: InboundOption = {
...inbound,
tuicServer: {
...inbound.tuicServer,
sni: '',
},
};
const cfg = buildTuicClientConfig(client, inboundNoSni, 'server.example.com', '');
expect(cfg).toContain('sni: server.example.com');
});
it('escapes quotes in passwords and remarks', () => {
const dangerousClient: ClientRecord = {
...client,
password: 'pass"with"quotes\nnewline',
};
const cfg = buildTuicClientConfig(dangerousClient, inbound, 'server.example.com', '');
expect(cfg).toContain('password: "pass\\"with\\"quotes\\nnewline"');
});
});