From aacfaebab8bc37dc874c7d36457accff9fa7fa2b Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 5 Oct 2026 22:46:46 +0500 Subject: [PATCH] fix(tuic): client speed display and certificate button layout (#6723) * fix(tuic): restore client speed and certificate layout * docs(tuic): clarify native runtime and protocol behavior * fix(websocket): preserve traffic updates from independent sources * fix(docs): sync websocket traffic schema and drop restating TUIC tests docs/public/openapi.json still described the old traffic event, without clientTrafficSource/clientTrafficIntervalMs or the TUIC oneOf branch, so the docs site showed a payload the panel no longer sends. No check covers that copy. The TUIC certificate layout test and the TUIC speed payload test only read back the literals the code writes, so neither could fail on a real regression. Both are removed, along with the className that existed only for the layout test. --------- Co-authored-by: MHSanaei --- README.md | 2 +- README.ru_RU.md | 2 +- docs/content/docs/en/config/tuic.mdx | 10 +-- docs/content/docs/ru/config/tuic.mdx | 10 +-- docs/public/openapi.json | 33 ++++++--- frontend/public/openapi.json | 33 ++++++--- frontend/src/hooks/useClients.ts | 64 ++++++++++++++++-- .../src/pages/api-docs/websocket-events.ts | 27 +++++++- .../pages/inbounds/form/protocols/tuic.tsx | 2 +- frontend/src/test/clients-summary.test.tsx | 67 +++++++++++++++++++ internal/web/job/tuic_job.go | 38 ++++++++++- internal/web/job/tuic_job_test.go | 11 +++ internal/web/websocket/hub.go | 4 +- internal/web/websocket/hub_test.go | 57 ++++++++++++---- 14 files changed, 310 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index adc47d7b4..9d9f3b6b7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto - **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN. - **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY. - **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install. -- **TUIC v5 sidecar** — High-performance QUIC-based proxy with native UDP relay traffic metering, 0-RTT handshakes, and BBR congestion control. +- **Native TUIC v5 server** — In-process Go QUIC server with Xray routing and per-client traffic accounting; BBR and New Reno are available server-side. CUBIC is preserved in the client profile but currently falls back to New Reno on the server. - **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections. - **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support. - **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions. diff --git a/README.ru_RU.md b/README.ru_RU.md index 3a64ab15b..0544871d4 100644 --- a/README.ru_RU.md +++ b/README.ru_RU.md @@ -29,7 +29,7 @@ - **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN. - **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY. - **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов. -- **Встроенный TUIC v5** — высокопроизводительный прокси на базе QUIC с нативным учётом трафика через UDP-релей, 0-RTT рукопожатиями и контролем перегрузок BBR. +- **Нативный TUIC v5** — Go QUIC-сервер работает внутри процесса панели. Трафик маршрутизируется через Xray, а учёт ведётся по клиентам. На сервере доступны BBR и New Reno; CUBIC сохраняется в профиле клиента, но на сервере пока использует New Reno. - **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения. - **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray. - **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик. diff --git a/docs/content/docs/en/config/tuic.mdx b/docs/content/docs/en/config/tuic.mdx index 7232a9f65..e2098101d 100644 --- a/docs/content/docs/en/config/tuic.mdx +++ b/docs/content/docs/en/config/tuic.mdx @@ -15,16 +15,16 @@ unstable networks. ## Key settings -### Server & QUIC parameters +### Server, QUIC & client-profile parameters | Field | Description | | --- | --- | | **Port** | UDP port for incoming client QUIC connections. | | **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. | -| **SNI** | Server Name Indication matching your TLS certificate domain name. | -| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. The server runs `bbr` or `new_reno`; `cubic` is sent to clients but served as `new_reno`. | +| **SNI** | Client-profile Server Name Indication. Set it to the domain covered by the server certificate; this field does not configure the listener certificate. | +| **Congestion Control** | QUIC congestion control algorithm used in the server setting and exported client profile: `bbr`, `cubic`, or `new_reno`. The server runs BBR or New Reno; when CUBIC is selected, the client profile keeps CUBIC while this server currently falls back to New Reno. | | **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). | -| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. | +| **UDP Relay Mode** | Client-profile packet mode: `native` (QUIC datagrams) or `quic` (unidirectional streams). The server accepts both modes regardless of this exported preference. | | **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. | | **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). | | **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). | @@ -105,6 +105,8 @@ tuic://:@:?congestion_control=bbr&alpn=h3&sni=vpn.ex - **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain. - **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in--udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.). - **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client. + - **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again. + - **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports. - **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions. - **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node. diff --git a/docs/content/docs/ru/config/tuic.mdx b/docs/content/docs/ru/config/tuic.mdx index ab01e5ccf..939a78e27 100644 --- a/docs/content/docs/ru/config/tuic.mdx +++ b/docs/content/docs/ru/config/tuic.mdx @@ -14,16 +14,16 @@ icon: Zap ## Ключевые параметры -### Параметры сервера и QUIC +### Параметры сервера, QUIC и клиентского профиля | Поле | Описание | | --- | --- | | **Порт** | UDP-порт для входящих QUIC-соединений клиентов. | | **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. | -| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. | -| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. Сервер работает с `bbr` или `new_reno`; `cubic` передаётся клиентам, но на сервере применяется как `new_reno`. | +| **SNI** | Server Name Indication для профиля клиента. Укажите домен, покрытый сертификатом сервера; это поле не настраивает сертификат listener'а. | +| **Контроль перегрузок** | Алгоритм QUIC в настройках сервера и экспортируемом профиле: `bbr`, `cubic` или `new_reno`. Сервер использует BBR или New Reno; при выборе CUBIC клиентский профиль сохраняет CUBIC, а сервер пока применяет New Reno. | | **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). | -| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. | +| **Режим UDP Relay** | Режим UDP в профиле клиента: `native` (QUIC datagrams) или `quic` (однонаправленные потоки). Сервер принимает оба режима независимо от этого значения. | | **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. | | **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). | | **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). | @@ -104,6 +104,8 @@ tuic://:@:?congestion_control=bbr&alpn=h3&sni=vpn.ex - **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется. - **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in--udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.). - **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента. + - **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет. + - **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами. - **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей. - **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 298f96690..43feed637 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -16027,15 +16027,9 @@ }, { "type": "traffic", - "summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.", + "summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.", "payloadSchema": { "type": "object", - "required": [ - "onlineClients", - "onlineByGuid", - "activeInbounds", - "lastOnlineMap" - ], "properties": { "traffics": { "type": "array", @@ -16049,6 +16043,18 @@ "$ref": "#/components/schemas/ClientTraffic" } }, + "clientTrafficSource": { + "type": "string", + "enum": [ + "xray", + "tuic" + ], + "description": "Present for native TUIC samples; omitted Xray samples default to xray." + }, + "clientTrafficIntervalMs": { + "type": "integer", + "description": "Sampling interval used to calculate client speed, in milliseconds." + }, "nodeTraffics": { "type": "array", "nullable": true, @@ -16092,13 +16098,24 @@ { "required": [ "traffics", - "clientTraffics" + "clientTraffics", + "onlineClients", + "onlineByGuid", + "activeInbounds", + "lastOnlineMap" ] }, { "required": [ "nodeTraffics" ] + }, + { + "required": [ + "clientTraffics", + "clientTrafficSource", + "clientTrafficIntervalMs" + ] } ] }, diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 298f96690..43feed637 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -16027,15 +16027,9 @@ }, { "type": "traffic", - "summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.", + "summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.", "payloadSchema": { "type": "object", - "required": [ - "onlineClients", - "onlineByGuid", - "activeInbounds", - "lastOnlineMap" - ], "properties": { "traffics": { "type": "array", @@ -16049,6 +16043,18 @@ "$ref": "#/components/schemas/ClientTraffic" } }, + "clientTrafficSource": { + "type": "string", + "enum": [ + "xray", + "tuic" + ], + "description": "Present for native TUIC samples; omitted Xray samples default to xray." + }, + "clientTrafficIntervalMs": { + "type": "integer", + "description": "Sampling interval used to calculate client speed, in milliseconds." + }, "nodeTraffics": { "type": "array", "nullable": true, @@ -16092,13 +16098,24 @@ { "required": [ "traffics", - "clientTraffics" + "clientTraffics", + "onlineClients", + "onlineByGuid", + "activeInbounds", + "lastOnlineMap" ] }, { "required": [ "nodeTraffics" ] + }, + { + "required": [ + "clientTraffics", + "clientTrafficSource", + "clientTrafficIntervalMs" + ] } ] }, diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 61d62508a..199a47a2e 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -98,6 +98,8 @@ export interface ClientSpeedEntry { down: number; } +type ClientSpeedSource = 'xray' | 'tuic'; + type ClientStatRow = ClientTraffic & { email?: string }; export function sameSpeedMap( @@ -299,7 +301,31 @@ export function useClients(options: UseClientsOptions = {}) { // settings request still lets the page fall back and render. const settingsReady = defaultsQuery.isFetched; - const [clientSpeed, setClientSpeed] = useState>({}); + const [clientSpeedBySource, setClientSpeedBySource] = useState< + Partial>> + >({}); + const clientSpeedExpiryTimers = useRef>>({}); + const clientSpeedSourceVersions = useRef>({ xray: 0, tuic: 0 }); + const clientSpeed = useMemo(() => { + const combined: Record = {}; + for (const source of Object.values(clientSpeedBySource)) { + if (!source) continue; + for (const [email, speed] of Object.entries(source)) { + const current = combined[email] ?? { up: 0, down: 0 }; + combined[email] = { up: current.up + speed.up, down: current.down + speed.down }; + } + } + return combined; + }, [clientSpeedBySource]); + + useEffect( + () => () => { + for (const timer of Object.values(clientSpeedExpiryTimers.current)) { + if (timer !== undefined) window.clearTimeout(timer); + } + }, + [], + ); const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY; const invalidateAll = useCallback(() => { @@ -725,6 +751,8 @@ export function useClients(options: UseClientsOptions = {}) { const p = payload as { onlineClients?: string[]; clientTraffics?: { email: string; up: number; down: number }[]; + clientTrafficSource?: 'xray' | 'tuic'; + clientTrafficIntervalMs?: number; }; if (Array.isArray(p.onlineClients)) { queryClient.setQueryData(keys.clients.onlines(), p.onlineClients); @@ -736,17 +764,45 @@ export function useClients(options: UseClientsOptions = {}) { // dropped and an unchanged result returns the previous object — which lets // React bail out of the update instead of re-rendering the table. const next: Record = {}; + const source = p.clientTrafficSource === 'tuic' ? 'tuic' : 'xray'; + const sampleIntervalMs = + typeof p.clientTrafficIntervalMs === 'number' && + Number.isFinite(p.clientTrafficIntervalMs) && + p.clientTrafficIntervalMs > 0 + ? p.clientTrafficIntervalMs + : TRAFFIC_POLL_INTERVAL_S * 1000; + const sampleIntervalSeconds = sampleIntervalMs / 1000; for (const ct of p.clientTraffics) { if (!ct || !ct.email) continue; const up = ct.up || 0; const down = ct.down || 0; if (up === 0 && down === 0) continue; + const current = next[ct.email] ?? { up: 0, down: 0 }; next[ct.email] = { - up: up / TRAFFIC_POLL_INTERVAL_S, - down: down / TRAFFIC_POLL_INTERVAL_S, + up: current.up + up / sampleIntervalSeconds, + down: current.down + down / sampleIntervalSeconds, }; } - setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next)); + setClientSpeedBySource((prev) => + sameSpeedMap(prev[source] ?? {}, next) ? prev : { ...prev, [source]: next }, + ); + + const version = ++clientSpeedSourceVersions.current[source]; + const previousTimer = clientSpeedExpiryTimers.current[source]; + if (previousTimer !== undefined) window.clearTimeout(previousTimer); + clientSpeedExpiryTimers.current[source] = window.setTimeout( + () => { + if (clientSpeedSourceVersions.current[source] !== version) return; + delete clientSpeedExpiryTimers.current[source]; + setClientSpeedBySource((prev) => { + if (!prev[source]) return prev; + const nextSources = { ...prev }; + delete nextSources[source]; + return nextSources; + }); + }, + Math.min(sampleIntervalMs * 2, 120_000), + ); } }, [queryClient], diff --git a/frontend/src/pages/api-docs/websocket-events.ts b/frontend/src/pages/api-docs/websocket-events.ts index 997d93449..3549a5b5d 100644 --- a/frontend/src/pages/api-docs/websocket-events.ts +++ b/frontend/src/pages/api-docs/websocket-events.ts @@ -120,10 +120,18 @@ const statusPayloadSchema = { const trafficPayloadSchema = { type: 'object', - required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'], properties: { traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } }, clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } }, + clientTrafficSource: { + type: 'string', + enum: ['xray', 'tuic'], + description: 'Present for native TUIC samples; omitted Xray samples default to xray.', + }, + clientTrafficIntervalMs: { + type: 'integer', + description: 'Sampling interval used to calculate client speed, in milliseconds.', + }, nodeTraffics: { type: 'array', nullable: true, @@ -134,7 +142,20 @@ const trafficPayloadSchema = { activeInbounds: stringArrayMap, lastOnlineMap: timestampMap, }, - oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }], + oneOf: [ + { + required: [ + 'traffics', + 'clientTraffics', + 'onlineClients', + 'onlineByGuid', + 'activeInbounds', + 'lastOnlineMap', + ], + }, + { required: ['nodeTraffics'] }, + { required: ['clientTraffics', 'clientTrafficSource', 'clientTrafficIntervalMs'] }, + ], }; const clientStatsPayloadSchema = { @@ -205,7 +226,7 @@ export function buildWebSocketEvents( { type: 'traffic', summary: - 'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.', + 'Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.', payloadSchema: trafficPayloadSchema, example: { type: 'traffic', diff --git a/frontend/src/pages/inbounds/form/protocols/tuic.tsx b/frontend/src/pages/inbounds/form/protocols/tuic.tsx index ee577dca9..cc6876732 100644 --- a/frontend/src/pages/inbounds/form/protocols/tuic.tsx +++ b/frontend/src/pages/inbounds/form/protocols/tuic.tsx @@ -166,7 +166,7 @@ export default function TuicFields() { - +