feat(tuic): implement native in-process Go TUIC v5 server (#6577)

* 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>
This commit is contained in:
Egor
2026-10-03 05:56:02 +05:00
committed by GitHub
parent 40ca2cd72f
commit 0054e671f8
100 changed files with 7942 additions and 1467 deletions
+35
View File
@@ -0,0 +1,35 @@
export type TuicCongestionController = 'bbr' | 'cubic' | 'new_reno';
export function normalizeTuicCongestionController(value: unknown): TuicCongestionController {
if (typeof value !== 'string' || value.trim() === '') return 'bbr';
switch (value.trim().toLowerCase()) {
case 'bbr':
return 'bbr';
case 'cubic':
return 'cubic';
case 'reno':
case 'new_reno':
return 'new_reno';
default:
return 'new_reno';
}
}
export function resolveTuicServerSettings(
settings: Record<string, unknown>,
): Record<string, unknown> {
const nested =
settings.server && typeof settings.server === 'object' && !Array.isArray(settings.server)
? (settings.server as Record<string, unknown>)
: {};
const result: Record<string, unknown> = { ...settings };
delete result.server;
delete result.clients;
for (const [key, value] of Object.entries(nested)) {
if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) continue;
if (typeof value === 'number' && value <= 0) continue;
result[key] = value;
}
return result;
}
@@ -1,3 +1,4 @@
import { resolveTuicServerSettings } from '@/lib/tuic';
import type {
InboundFormValues,
ShareAddrStrategy,
@@ -168,7 +169,12 @@ function stripTlsCertUseFile(stream: Record<string, unknown>): void {
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
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;
+5 -3
View File
@@ -17,6 +17,7 @@ import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -914,15 +915,16 @@ export function genTuicLink(input: GenTuicLinkInput): string {
if (!clientUuid || !clientPassword) return '';
const rawSettings = inbound.settings as Record<string, unknown>;
const server = (rawSettings.server as Record<string, unknown>) ?? rawSettings;
const server = resolveTuicServerSettings(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';
const cc = normalizeTuicCongestionController(
server.congestion_control ?? rawSettings.congestion_control,
);
url.searchParams.set('congestion_control', cc);
const epAlpn = externalProxyAlpn(externalProxy?.alpn);
@@ -130,19 +130,6 @@ 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', '');
@@ -405,9 +392,7 @@ export default function ClientBulkAddModal({
<FormField
name="totalGB"
label={t('pages.clients.totalGB')}
tooltip={
hasTuic ? t('pages.clients.tuicTotalGBDesc') : t('pages.clients.totalGBDesc')
}
tooltip={t('pages.clients.totalGBDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} step={1} />
+1 -18
View File
@@ -452,19 +452,6 @@ 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);
@@ -884,11 +871,7 @@ export default function ClientFormModal({
<FormField
name="totalGB"
label={t('pages.clients.totalGB')}
tooltip={
hasTuic
? t('pages.clients.tuicTotalGBDesc')
: t('pages.clients.totalGBDesc')
}
tooltip={t('pages.clients.totalGBDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
+2 -1
View File
@@ -1,6 +1,7 @@
import type { HostEndpoint } from '@/lib/hosts/host-link';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import { normalizeTuicCongestionController } from '@/lib/tuic';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
export function isTuicClient(client: ClientRecord | null | undefined): boolean {
@@ -39,7 +40,7 @@ export function buildTuicClientConfig(
? tuicServer.alpn
: ['h3', 'spdy/3.1'];
const sni = hostEndpoint?.sni || tuicServer?.sni || endpointHost;
const cc = tuicServer?.congestion_control || 'bbr';
const cc = normalizeTuicCongestionController(tuicServer?.congestion_control);
const udpRelay = tuicServer?.udp_relay_mode || 'native';
const reduceRtt = tuicServer?.zero_rtt_handshake ?? true;
@@ -4,6 +4,7 @@ import {
AutoComplete,
Button,
Collapse,
Divider,
Form,
Input,
InputNumber,
@@ -20,7 +21,7 @@ import { HttpUtil } from '@/utils';
export default function TuicFields() {
const { t } = useTranslation();
const { control, setValue, getValues } = useFormContext();
const { control, setValue } = useFormContext();
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
const sni = (useWatch({ control, name: 'settings.server.sni' }) ?? '') as string;
@@ -30,24 +31,12 @@ export default function TuicFields() {
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'));
message.warning(t('pages.xray.tuic.sniRequired'));
return;
}
setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
@@ -147,7 +136,7 @@ export default function TuicFields() {
name={['settings', 'server', 'max_udp_relay_packet_size']}
label={t('pages.xray.tuic.maxUdpRelayPacketSize')}
>
<InputNumber min={1} style={{ width: '100%' }} />
<InputNumber min={1} max={65245} style={{ width: '100%' }} />
</FormField>
</>
),
@@ -156,20 +145,6 @@ export default function TuicFields() {
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}
@@ -198,6 +173,9 @@ export default function TuicFields() {
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button icon={<SyncOutlined />} onClick={autofillFromSni}>
{t('pages.inbounds.form.autoFill')}
</Button>
<Button
danger
onClick={() => {
@@ -213,6 +191,7 @@ export default function TuicFields() {
<FormField
name={['settings', 'server', 'congestion_control']}
label={t('pages.xray.tuic.congestionControl')}
tooltip={t('pages.xray.tuic.congestionControlHint')}
>
<Select
options={[
@@ -234,9 +213,20 @@ export default function TuicFields() {
/>
</FormField>
<Divider titlePlacement="start">{t('pages.xray.tuic.profileOptions')}</Divider>
<Form.Item label={t('pages.xray.tuic.sni')} tooltip={t('pages.xray.tuic.sniHint')}>
<Input
value={sni}
placeholder="example.com"
onChange={(e) => handleSniChange(e.target.value)}
/>
</Form.Item>
<FormField
name={['settings', 'server', 'udp_relay_mode']}
label={t('pages.xray.tuic.udpRelayMode')}
tooltip={t('pages.xray.tuic.udpRelayModeHint')}
>
<Select
options={[
+25 -5
View File
@@ -1,11 +1,23 @@
import { z } from 'zod';
import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
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);
const congestionController = z.preprocess(
normalizeTuicCongestionController,
z.enum(['bbr', 'cubic', 'new_reno']),
);
const maxUdpRelayPacketSize = z.preprocess(
(value) => (typeof value === 'number' && value > 65245 && value <= 65507 ? 65245 : value),
z.number().int().min(1).max(65245),
);
export const TuicClientSchema = z.object({
uuid: z.string().optional(),
id: z.string().optional(),
@@ -30,31 +42,39 @@ 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'),
congestion_control: congestionController.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)),
max_udp_relay_packet_size: clearedToDefault(maxUdpRelayPacketSize.default(1500)),
sni: z.string().default(''),
});
export type TuicServer = z.infer<typeof TuicServerSchema>;
export const TuicInboundSettingsSchema = z.object({
const TuicSettingsObject = z.object({
server: TuicServerSchema.optional(),
certificate: z.string().optional(),
private_key: z.string().optional(),
congestion_control: z.string().optional(),
congestion_control: congestionController.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)),
max_udp_relay_packet_size: z.preprocess(
(value) => (typeof value === 'number' && value > 65245 && value <= 65507 ? 65245 : value),
optionalClearedInt(z.number().int().min(1).max(65245)),
),
sni: z.string().optional(),
clients: z.array(TuicClientSchema).default([]),
});
export const TuicInboundSettingsSchema = z.preprocess((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
const raw = value as Record<string, unknown>;
return { ...raw, server: resolveTuicServerSettings(raw) };
}, TuicSettingsObject);
export type TuicInboundSettings = z.infer<typeof TuicInboundSettingsSchema>;
@@ -178,6 +178,48 @@ describe('createDefault*InboundSettings factories', () => {
});
});
describe('TuicInboundSettingsSchema', () => {
it('canonicalizes congestion-controller aliases and casing', () => {
expect(
TuicInboundSettingsSchema.parse({ server: { congestion_control: 'RENO' } }).server
?.congestion_control,
).toBe('new_reno');
expect(
TuicInboundSettingsSchema.parse({ server: { congestion_control: ' CuBiC ' } }).server
?.congestion_control,
).toBe('cubic');
expect(
TuicInboundSettingsSchema.parse({ server: { congestion_control: '' } }).server
?.congestion_control,
).toBe('bbr');
expect(
TuicInboundSettingsSchema.parse({ server: { congestion_control: ' ' } }).server
?.congestion_control,
).toBe('bbr');
expect(
TuicInboundSettingsSchema.parse({ congestion_control: ' CuBiC ' }).congestion_control,
).toBe('cubic');
expect(
TuicInboundSettingsSchema.parse({ congestion_control: 'invalid' }).congestion_control,
).toBe('new_reno');
});
it('clamps legacy packet-size values to the SOCKS-safe UDP payload maximum', () => {
expect(
TuicInboundSettingsSchema.parse({ server: { max_udp_relay_packet_size: 65507 } }).server
?.max_udp_relay_packet_size,
).toBe(65245);
expect(
TuicInboundSettingsSchema.parse({ max_udp_relay_packet_size: 65500 })
.max_udp_relay_packet_size,
).toBe(65245);
expect(() =>
TuicInboundSettingsSchema.parse({ server: { max_udp_relay_packet_size: 65508 } }),
).toThrow();
expect(() => TuicInboundSettingsSchema.parse({ max_udp_relay_packet_size: 65508 })).toThrow();
});
});
describe('createHysteriaTlsSettingsWithDefaultCert', () => {
it('defaults Hysteria TLS to uTLS None and h3 ALPN', () => {
const tls = createHysteriaTlsSettingsWithDefaultCert();
+35
View File
@@ -1232,6 +1232,41 @@ describe('genVlessLink XHTTP extra compatibility', () => {
});
describe('genTuicLink', () => {
it('canonicalizes legacy flat controller values to the Go runtime default', () => {
const cases = [
{ value: '', expected: 'bbr' },
{ value: ' ', expected: 'bbr' },
{ value: 'BBR', expected: 'bbr' },
{ value: ' CuBiC ', expected: 'cubic' },
{ value: 'reno', expected: 'new_reno' },
{ value: 'invalid', expected: 'new_reno' },
];
for (const { value, expected } of cases) {
const inbound = InboundSchema.parse({
id: 10,
protocol: 'tuic',
port: 8443,
settings: {
congestion_control: value,
clients: [
{
uuid: '11111111-2222-3333-4444-555555555555',
password: 'secretpassword',
email: 'user@tuic',
},
],
},
});
const link = genTuicLink({
inbound,
address: 'example.com',
clientUuid: '11111111-2222-3333-4444-555555555555',
clientPassword: 'secretpassword',
});
expect(new URL(link).searchParams.get('congestion_control')).toBe(expected);
}
});
it('builds a standard tuic share link with all parameters', () => {
const inbound = InboundSchema.parse({
id: 1,
@@ -52,6 +52,16 @@ describe('buildTuicClientConfig', () => {
expect(cfg).toContain('sni: server.example.com');
});
it('exports canonical controller values for legacy settings', () => {
const legacyInbound = {
...inbound,
tuicServer: { ...inbound.tuicServer, congestion_control: ' RENO ' },
} as InboundOption;
const cfg = buildTuicClientConfig(client, legacyInbound, 'server.example.com', '');
expect(cfg).toContain('congestion-controller: new_reno');
expect(cfg).not.toContain('congestion-controller: RENO ');
});
it('escapes quotes in passwords and remarks', () => {
const dangerousClient: ClientRecord = {
...client,
@@ -60,4 +70,42 @@ describe('buildTuicClientConfig', () => {
const cfg = buildTuicClientConfig(dangerousClient, inbound, 'server.example.com', '');
expect(cfg).toContain('password: "pass\\"with\\"quotes\\nnewline"');
});
it.each([
['bbr', 'bbr'],
['cubic', 'cubic'],
[' RENO ', 'new_reno'],
['unknown', 'new_reno'],
])('keeps controller %s and relay settings when applying a Host', (controller, expected) => {
const config = buildTuicClientConfig(
client,
{
...inbound,
tuicServer: {
...inbound.tuicServer,
congestion_control: controller,
udp_relay_mode: 'quic',
zero_rtt_handshake: false,
},
} as InboundOption,
'panel.example.com',
'',
{
dest: 'edge.example.com',
port: 9443,
remark: 'Edge',
sni: 'edge.sni.example.com',
alpn: ['h3'],
allowInsecure: true,
},
);
expect(config).toContain('server: edge.example.com');
expect(config).toContain('port: 9443');
expect(config).toContain('sni: edge.sni.example.com');
expect(config).toContain('alpn:\n - h3\n');
expect(config).toContain(`congestion-controller: ${expected}`);
expect(config).toContain('udp-relay-mode: quic');
expect(config).toContain('reduce-rtt: false');
expect(config).toContain('skip-cert-verify: true');
});
});
@@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { describe, expect, it } from 'vitest';
import { FormProvider, useForm, type UseFormReturn } from 'react-hook-form';
import { render, act } from '@testing-library/react';
import { Form } from 'antd';
import TuicFields from '@/pages/inbounds/form/protocols/tuic';
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { InboundFormSchema, type InboundFormValues } from '@/schemas/forms/inbound-form';
import { genTuicLink } from '@/lib/xray/inbound-link';
const legacy = {
certificate: '/old/cert.pem',
private_key: '/old/key.pem',
congestion_control: 'cubic',
log_level: 'error',
udp_relay_mode: 'quic',
sni: 'old.example',
zero_rtt_handshake: false,
max_idle_time: 77,
authentication_timeout: 11,
max_udp_relay_packet_size: 8192,
};
const row = { protocol: 'tuic', port: 8443, settings: legacy };
let methods: UseFormReturn<InboundFormValues>;
function Harness() {
const form = useForm<InboundFormValues>({ defaultValues: rawInboundToFormValues(row) as never });
useEffect(() => {
methods = form;
}, [form]);
return (
<FormProvider {...form}>
<Form>
<TuicFields />
</Form>
</FormProvider>
);
}
describe('TUIC legacy edit and raw profile precedence', () => {
it('mounting old flat settings must display current certificate', () => {
render(<Harness />);
const displayed = Array.from(document.querySelectorAll('input')).map((x) => x.value);
expect(displayed).toContain('/old/cert.pem');
});
it('editing only SNI must preserve controller and runtime values', () => {
render(<Harness />);
act(() => methods.setValue('settings.server.sni', 'new.example'));
const parsed = InboundFormSchema.parse(methods.getValues());
const saved = JSON.parse(formValuesToWirePayload(parsed).settings);
expect.soft(saved.server.congestion_control).toBe('cubic');
expect.soft(saved.server.log_level).toBe('error');
expect.soft(saved.server.zero_rtt_handshake).toBe(false);
expect.soft(saved.server.max_idle_time).toBe(77);
expect.soft(saved.server.udp_relay_mode).toBe('quic');
expect.soft(saved.server.authentication_timeout).toBe(11);
expect.soft(saved.server.max_udp_relay_packet_size).toBe(8192);
});
it('mounting then saving without TUIC changes must preserve flat values', () => {
render(<Harness />);
const saved = JSON.parse(
formValuesToWirePayload(InboundFormSchema.parse(methods.getValues())).settings,
);
expect.soft(saved.server?.congestion_control ?? saved.congestion_control).toBe('cubic');
expect.soft(saved.server?.sni || saved.sni).toBe('old.example');
expect.soft(saved.server?.udp_relay_mode ?? saved.udp_relay_mode).toBe('quic');
});
it('empty nested controller must match runtime legacy flat fallback', () => {
const link = genTuicLink({
inbound: {
protocol: 'tuic',
port: 8443,
settings: { ...legacy, server: { congestion_control: '' } },
} as never,
address: 'proxy.example',
clientUuid: '11111111-1111-1111-1111-111111111111',
clientPassword: 'dummy',
});
expect(new URL(link).searchParams.get('congestion_control')).toBe('cubic');
});
});