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 10:15:48 +02:00
committed by GitHub
co-authored by poise52 Sanaei
parent 0a2cd789ba
commit 6d96accd63
100 changed files with 4139 additions and 52 deletions
+25
View File
@@ -180,6 +180,28 @@ jobs:
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
;;
esac
case "${{ matrix.platform }}" in
amd64)
curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-unknown-linux-musl"
mv "tuic-server-1.0.0-x86_64-unknown-linux-musl" "tuic-server"
chmod +x "tuic-server"
;;
arm64)
curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-aarch64-unknown-linux-musl"
mv "tuic-server-1.0.0-aarch64-unknown-linux-musl" "tuic-server"
chmod +x "tuic-server"
;;
armv7)
curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-armv7-unknown-linux-musleabihf"
mv "tuic-server-1.0.0-armv7-unknown-linux-musleabihf" "tuic-server"
chmod +x "tuic-server"
;;
386)
curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-i686-unknown-linux-musl"
mv "tuic-server-1.0.0-i686-unknown-linux-musl" "tuic-server"
chmod +x "tuic-server"
;;
esac
cd ../..
- name: Package
@@ -312,6 +334,9 @@ jobs:
Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
# TUIC sidecar for Windows
curl.exe -sfLRo "tuic-server-windows-amd64.exe" --retry 5 --retry-all-errors --retry-delay 3 "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-pc-windows-msvc.exe"
cd ..
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
cd ..
+22
View File
@@ -1,4 +1,5 @@
#!/bin/sh
set -e
case $1 in
amd64)
ARCH="64"
@@ -49,6 +50,27 @@ tar -xzf "${MTG_PKG}.tar.gz"
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${FNAME}"
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
chmod +x "mtg-linux-${FNAME}"
case $FNAME in
amd64)
curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-unknown-linux-musl"
;;
arm64)
curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-aarch64-unknown-linux-musl"
;;
arm32)
curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-armv7-unknown-linux-musleabihf"
;;
i386)
curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-i686-unknown-linux-musl"
;;
esac
if [ -f "tuic-server" ]; then
if [ ! -s "tuic-server" ]; then
echo "DockerInit: tuic-server download was empty" >&2
exit 1
fi
chmod +x "tuic-server"
fi
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
+1
View File
@@ -64,6 +64,7 @@ The inbound editor accepts these protocols:
| **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener. |
| **Dokodemo-door / Tunnel** | Port forwarding / traffic redirect. |
| **MTProto** | Telegram MTProto proxy, served by a bundled `mtg` process (not Xray). |
| **TUIC** | QUIC-based proxy protocol (v5), served by a bundled `tuic-server` process. See [TUIC](/docs/config/tuic). |
<Callout type="info">
Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol
+1
View File
@@ -7,6 +7,7 @@
"inbounds",
"reality",
"amneziawg",
"tuic",
"transports",
"clients",
"subscription",
@@ -17,6 +17,7 @@ as v2rayNG, Hiddify, and Mihomo import these links to configure themselves.
| `ss://` | `ss://<userinfo>@<host>:<port>?<params>#<remark>` (SIP002; Shadowsocks-2022 uses percent-encoded userinfo) |
| `hysteria2://` | `hysteria2://<auth>@<host>:<port>?<params>#<remark>` |
| `tg://proxy` | `tg://proxy?server=…&port=…&secret=…` (MTProto) |
| `tuic://` | `tuic://<uuid>:<password>@<host>:<port>?<params>#<remark>` (TUIC v5) |
The query parameters carry the transport and security settings — `security`,
`sni`, `fp`, `pbk`, `sid`, `spx`, `flow`, `type`, `path`, `host`, `alpn`, and
+113
View File
@@ -0,0 +1,113 @@
---
title: TUIC
description: Set up a TUIC inbound in 3x-ui — QUIC congestion control, 0-RTT handshakes, and multi-user authentication.
icon: Zap
---
**TUIC** (v5) is a proxy protocol built directly on top of the **QUIC** (HTTP/3) transport
layer. It uses 0-RTT handshakes, connection multiplexing without head-of-line blocking,
and custom congestion control algorithms to maintain stable connections over lossy or
unstable networks.
<Callout type="info">
Like MTProto, TUIC runs as a **managed sidecar process** (`tuic-server` 1.0.0,
written in Rust) rather than inside Xray-core. The panel manages the binary
lifecycle, generates configurations, monitors process health, and tracks
inbound traffic and client online presence.
</Callout>
## Key settings
### Server & QUIC 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`. |
| **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. |
| **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`). |
| **Max Packet Size** | Maximum UDP relay packet size in bytes (default: `1500`). |
## Set it up in the panel
<Steps>
<Step>
### Add an inbound
Create a new inbound and choose protocol **TUIC**. Assign a UDP port (e.g. `8443` or `443`).
</Step>
<Step>
### Select TLS certificate
Provide the certificate file path and private key file path (or paste their contents). Make sure the configured SNI matches the certificate domain.
</Step>
<Step>
### Configure QUIC options
The panel fills recommended defaults (`bbr`, `h3`, `native` UDP relay). Adjust timeouts or enable **Zero-RTT Handshake** if desired.
</Step>
<Step>
### Add clients
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. The panel automatically generates secure random credentials when creating clients.
</Step>
<Step>
### Export and connect
Copy the client's share link (`tuic://…`) or open the **QR modal** to download a ready-to-use **Clash / Mihomo YAML** configuration.
</Step>
</Steps>
## Client support & configuration
TUIC v5 is supported by modern proxy clients including **Clash Verge Rev**, **Mihomo**, **Flclash**, **sing-box**, and **v2rayN**.
### Clash / Mihomo configuration
The panel provides automatic YAML export for Clash/Mihomo in the client QR modal:
```yaml title="clash-tuic.yaml"
proxies:
- name: "3x-ui-tuic"
type: tuic
server: vpn.example.com
port: 8443
uuid: 8a47f2b1-5e8c-4a3d-9b1e-7f6c5d4a3b2a
password: secure-random-password
alpn:
- h3
sni: vpn.example.com
congestion-controller: bbr
udp-relay-mode: native
reduce-rtt: false
skip-cert-verify: false
```
### Share link format
TUIC share links use standard URI formatting:
```text
tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark
```
## Architecture & Notes
<Callout type="info">
- **Standalone sidecar**: The panel ships pre-compiled `tuic-server` musl binaries on Linux (amd64, arm64, armv7, 386) and executable for Windows.
- **Traffic accounting & limits**: The panel owns the inbound's public UDP port with a small relay and runs `tuic-server` behind it on a loopback port, so the inbound's upload and download bytes are counted exactly on every OS and enforced at the **inbound level** (`inbounds.total`); `tuic-server` therefore logs `127.0.0.1` as every client's address. Because upstream `tuic-server` does not provide an internal per-user metrics API, individual client traffic limits (`totalGB`) are not supported for TUIC clients. Client access can be controlled via expiration timestamps (`expiryTime`) and manual enable/disable toggles.
- **Online status & "start after first use"**: The panel detects a client's activity from the sidecar's Info log lines (they carry the client UUID), so those features need the inbound's log level at `info` or `debug`; `warn` and `error` silence them.
- **Client updates & connections**: Because upstream `tuic-server` lacks dynamic user reload APIs, client modifications (adding, updating, or disabling clients) restart the sidecar process and momentarily reset active connections.
- **Deployment**: Because TUIC operates via a host sidecar process, TUIC inbounds are panel-local (main instance).
</Callout>
+1
View File
@@ -64,6 +64,7 @@ icon: ArrowDownToLine
| **Mixed (SOCKS/HTTP)** | Совмещённый слушатель SOCKS + HTTP. |
| **Dokodemo-door / Tunnel** | Перенаправление портов / перенаправление трафика. |
| **MTProto** | Прокси Telegram MTProto, обслуживаемый встроенным процессом `mtg` (не Xray). |
| **TUIC** | Протокол проксирования на базе QUIC (v5), обслуживаемый встроенным процессом `tuic-server`. См. [TUIC](/docs/config/tuic). |
<Callout type="info">
Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria`
+1
View File
@@ -6,6 +6,7 @@
"ssl-certificates",
"inbounds",
"reality",
"tuic",
"transports",
"clients",
"subscription",
@@ -17,6 +17,7 @@ icon: Link
| `ss://` | `ss://<userinfo>@<host>:<port>?<params>#<remark>` (SIP002; Shadowsocks-2022 использует userinfo с процентным кодированием) |
| `hysteria2://` | `hysteria2://<auth>@<host>:<port>?<params>#<remark>` |
| `tg://proxy` | `tg://proxy?server=…&port=…&secret=…` (MTProto) |
| `tuic://` | `tuic://<uuid>:<password>@<host>:<port>?<params>#<remark>` (TUIC v5) |
Параметры запроса несут настройки транспорта и безопасности — `security`,
`sni`, `fp`, `pbk`, `sid`, `spx`, `flow`, `type`, `path`, `host`, `alpn` и
+112
View File
@@ -0,0 +1,112 @@
---
title: TUIC
description: Настройка входящего подключения TUIC в 3x-ui — параметры перегрузок QUIC, 0-RTT рукопожатия и многопользовательская аутентификация.
icon: Zap
---
**TUIC** (v5) — это протокол проксирования, работающий поверх транспортного уровня **QUIC** (HTTP/3).
Он использует 0-RTT рукопожатия, мультиплексирование соединений без блокировки начала очереди
и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
<Callout type="info">
Как и MTProto, TUIC работает как **изолированный процесс-сайдкар** (`tuic-server` 1.0.0,
написан на Rust), а не внутри Xray-core. Панель управляет жизненным циклом бинарника,
генерирует конфигурации, отслеживает его состояние, фиксирует общий трафик инбаунда
и онлайн-активность клиентов.
</Callout>
## Ключевые параметры
### Параметры сервера и QUIC
| Поле | Описание |
| --- | --- |
| **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
| **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. |
| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. |
| **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. |
| **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
| **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
| **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
| **Максимальный размер пакета** | Максимальный размер пакета UDP-релея в байтах (по умолчанию: `1500`). |
## Настройка в панели
<Steps>
<Step>
### Добавьте инбаунд
Создайте новый инбаунд и выберите протокол **TUIC**. Задайте UDP-порт (например, `8443` или `443`).
</Step>
<Step>
### Укажите TLS-сертификат
Укажите пути к файлам сертификата и приватного ключа (или вставьте их содержимое напрямую). Убедитесь, что поле SNI совпадает с доменом сертификата.
</Step>
<Step>
### Настройте параметры QUIC
Панель автоматически подставляет рекомендованные настройки (`bbr`, `h3`, `native`). При необходимости настройте таймауты или включите **Zero-RTT Handshake**.
</Step>
<Step>
### Добавьте клиентов
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Панель автоматически генерирует надёжные случайные данные при создании клиента.
</Step>
<Step>
### Экспортируйте и подключитесь
Скопируйте ссылку `tuic://…` или откройте **окно QR-кода**, чтобы скачать готовый конфигурационный файл **Clash / Mihomo YAML**.
</Step>
</Steps>
## Поддержка клиентами и конфигурация
TUIC v5 поддерживается всеми популярными клиентами, включая **Clash Verge Rev**, **Mihomo**, **Flclash**, **sing-box** и **v2rayN**.
### Конфигурация Clash / Mihomo
Панель предоставляет автоматический экспорт в формат YAML прямо в окне QR-кода клиента:
```yaml title="clash-tuic.yaml"
proxies:
- name: "3x-ui-tuic"
type: tuic
server: vpn.example.com
port: 8443
uuid: 8a47f2b1-5e8c-4a3d-9b1e-7f6c5d4a3b2a
password: secure-random-password
alpn:
- h3
sni: vpn.example.com
congestion-controller: bbr
udp-relay-mode: native
reduce-rtt: false
skip-cert-verify: false
```
### Формат ссылки для обмена
Ссылки TUIC используют стандартный формат URI:
```text
tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark
```
## Архитектура и примечания
<Callout type="info">
- **Автономный сайдкар**: Панель поставляется со скомпилированными статическими `musl`-бинарниками `tuic-server` для Linux (amd64, arm64, armv7, 386) и исполняемым файлом для Windows.
- **Учёт трафика и лимиты**: Панель сама занимает публичный UDP-порт инбаунда небольшим relay и запускает `tuic-server` за ним на loopback-порту, поэтому входящие и исходящие байты инбаунда считаются точно на любой ОС и ограничиваются на **уровне инбаунда** (`inbounds.total`); в логах `tuic-server` адресом каждого клиента будет `127.0.0.1`. Поскольку апстрим `tuic-server` не предоставляет внутреннего API метрик по отдельным пользователям, персональные квоты трафика (`totalGB`) для клиентов TUIC не поддерживаются. Доступ клиентов контролируется по сроку действия (`expiryTime`) и переключателю активности.
- **Статус онлайн и «старт после первого использования»**: Панель определяет активность клиента по строкам Info в логе сайдкара (в них есть UUID клиента), поэтому этим функциям нужен уровень логов `info` или `debug`; `warn` и `error` их отключают.
- **Изменения клиентов и соединения**: Поскольку апстрим `tuic-server` не поддерживает динамическую перезагрузку пользователей без перезапуска, любое изменение списка клиентов (добавление, редактирование или отключение) перезапускает процесс сайдкара и кратковременно сбрасывает активные соединения.
- **Развёртывание**: Поскольку TUIC управляется локальным процессом хоста, такие инбаунды работают локально на главной панели.
</Callout>
+83 -1
View File
@@ -2604,7 +2604,8 @@
"tunnel",
"tun",
"mtproto",
"amneziawg"
"amneziawg",
"tuic"
],
"example": "vless",
"type": "string"
@@ -2823,6 +2824,14 @@
"example": true,
"type": "boolean"
},
"tuicServer": {
"allOf": [
{
"$ref": "#/components/schemas/TuicServerSettings"
}
],
"nullable": true
},
"wgDns": {
"type": "string"
},
@@ -4076,6 +4085,78 @@
],
"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": {
@@ -4568,6 +4649,7 @@
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true,
"tuicServer": null,
"wgDns": "",
"wgMtu": 0,
"wgPublicKey": ""
+83 -1
View File
@@ -2604,7 +2604,8 @@
"tunnel",
"tun",
"mtproto",
"amneziawg"
"amneziawg",
"tuic"
],
"example": "vless",
"type": "string"
@@ -2823,6 +2824,14 @@
"example": true,
"type": "boolean"
},
"tuicServer": {
"allOf": [
{
"$ref": "#/components/schemas/TuicServerSettings"
}
],
"nullable": true
},
"wgDns": {
"type": "string"
},
@@ -4076,6 +4085,78 @@
],
"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": {
@@ -4568,6 +4649,7 @@
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true,
"tuicServer": null,
"wgDns": "",
"wgMtu": 0,
"wgPublicKey": ""
+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"');
});
});
+35 -1
View File
@@ -367,6 +367,34 @@ install_acme() {
return 0
}
install_tuic_server() {
local target_arch=""
case "$(arch)" in
amd64|x86_64) target_arch="x86_64-unknown-linux-musl" ;;
arm64|aarch64) target_arch="aarch64-unknown-linux-musl" ;;
armv7|armv7l) target_arch="armv7-unknown-linux-musleabihf" ;;
386|i386|i686) target_arch="i686-unknown-linux-musl" ;;
armv6|armv6l|armv5|armv5l|s390x)
echo -e "${yellow}tuic-server does not provide prebuilt binaries for $(arch); TUIC inbounds will be unavailable on this machine${plain}"
return 0
;;
*) return 0 ;;
esac
local tuic_url="https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-${target_arch}"
echo -e "${green}Installing tuic-server (${target_arch})...${plain}"
mkdir -p "${xui_folder}/bin"
if curl -fLR --connect-timeout 15 --retry 3 -o "${xui_folder}/bin/tuic-server" "${tuic_url}" && [[ -s "${xui_folder}/bin/tuic-server" ]]; then
chmod +x "${xui_folder}/bin/tuic-server"
cp -f "${xui_folder}/bin/tuic-server" /usr/local/bin/tuic-server 2>/dev/null || true
chmod +x /usr/local/bin/tuic-server 2>/dev/null || true
echo -e "${green}tuic-server installed successfully${plain}"
else
rm -f "${xui_folder}/bin/tuic-server"
echo -e "${yellow}Failed to download tuic-server (optional), skipping${plain}"
fi
}
setup_ssl_certificate() {
local domain="$1"
local server_ip="$2"
@@ -1570,6 +1598,7 @@ install_x-ui() {
# an inbound port with an outdated secret, silently breaking new clients.
# The freshly installed panel respawns a clean mtg per inbound on start.
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
pkill -f 'tuic-server.*-c .*bin/tuic/tuic_[0-9]+\.json' > /dev/null 2>&1 || true
# bin/ is about to be wiped wholesale by the tar extraction below. The
# release only ships known assets (xray/mtg binaries, the bundled
@@ -1634,6 +1663,11 @@ install_x-ui() {
elif [[ -f bin/mtg-linux-$(arch) ]]; then
chmod +x bin/mtg-linux-$(arch)
fi
if [[ -f bin/tuic-server ]]; then
chmod +x bin/tuic-server
else
install_tuic_server
fi
# Restore anything from the old bin/ that the fresh release doesn't ship
# (custom geoip/geosite files, or anything else an admin hand-placed
@@ -1652,7 +1686,7 @@ install_x-ui() {
while IFS= read -r -d '' f; do
local rel="${f#"${custom_bin_backup}"/}"
case "${rel}" in
config.json | mtproto | mtproto/*) continue ;;
config.json | mtproto | mtproto/* | tuic | tuic/*) continue ;;
esac
if [[ ! -e "bin/${rel}" ]]; then
mkdir -p "bin/$(dirname "${rel}")"
+2 -1
View File
@@ -33,6 +33,7 @@ const (
Hysteria Protocol = "hysteria"
MTProto Protocol = "mtproto"
AmneziaWG Protocol = "amneziawg"
TUIC Protocol = "tuic"
)
// User represents a user account in the 3x-ui panel.
@@ -62,7 +63,7 @@ type Inbound struct {
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg" example:"vless"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg tuic" example:"vless"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
+58
View File
@@ -13,6 +13,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
@@ -401,6 +402,9 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
if inbound.Protocol == model.WireGuard {
return s.buildWireguardProxy(subReq, inbound, client, ep)
}
if inbound.Protocol == model.TUIC {
return s.buildTuicProxy(subReq, inbound, client, ep)
}
if inbound.Protocol == model.AmneziaWG {
return s.buildAmneziaWGProxy(subReq, inbound, client, ep)
}
@@ -611,6 +615,60 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
return proxy
}
func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
inst, ok := tuic.InstanceFromInbound(inbound)
if !ok {
return nil
}
uuid := client.ID
password := client.Password
for _, c := range inst.Clients {
if c.Email == client.Email {
if uuid == "" {
uuid = c.UUID
}
if password == "" {
password = c.Password
}
break
}
}
if uuid == "" || password == "" {
return nil
}
server := inbound.Listen
if server == "" || server == "0.0.0.0" || server == "::" {
server = subReq.resolveInboundAddress(inbound)
}
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, "tuic"),
"type": "tuic",
"server": server,
"port": inbound.Port,
"uuid": uuid,
"password": password,
"congestion-controller": inst.CongestionControl,
"udp-relay-mode": inst.UDPRelayMode,
"reduce-rtt": inst.ZeroRTTHandshake,
}
if len(inst.ALPN) > 0 {
proxy["alpn"] = inst.ALPN
}
if inst.SNI != "" {
proxy["sni"] = inst.SNI
}
if sni, ok := externalProxySNI(ep); ok {
proxy["sni"] = sni
}
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
proxy["alpn"] = strings.Split(alpn, ",")
}
if ai, ok := ep["allowInsecure"].(bool); ok && ai {
proxy["skip-cert-verify"] = true
}
return proxy
}
// amneziaWGClientAddresses prefers this inbound's own settings entry over the
// shared clients.wg_allowed_ips column, which for an identity attached to both
// a wireguard and an amneziawg inbound holds the other one's address.
+1 -1
View File
@@ -649,7 +649,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
continue
}
newOutbounds = append(newOutbounds, wgOutbound)
case "amneziawg":
case "amneziawg", "tuic":
continue
}
+11
View File
@@ -520,3 +520,14 @@ func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
}
}
func TestSubJsonServiceSkipsTUIC(t *testing.T) {
if got := NewSubJsonService("", "", "", "", nil).getConfig(
&SubService{address: "sub.example.com"},
&model.Inbound{Listen: "203.0.113.8", Port: 8443, Protocol: model.TUIC},
model.Client{},
"sub.example.com",
); len(got) != 0 {
t.Fatalf("getConfig emitted %d unsupported TUIC Xray config(s)", len(got))
}
}
+79 -1
View File
@@ -22,6 +22,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -583,7 +584,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
JOIN clients ON clients.id = client_inbounds.client_id
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto')
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
@@ -736,10 +737,87 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genWireguardLink(inbound, email)
case "amneziawg":
return s.genAmneziaWGLink(inbound, email)
case "tuic":
return s.genTuicLink(inbound, email)
}
return ""
}
func (s *SubService) genTuicLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.TUIC {
return ""
}
inst, ok := tuic.InstanceFromInbound(inbound)
if !ok {
return ""
}
var client *tuic.TuicClientSettings
for _, c := range inst.Clients {
if c.Email == email {
client = &c
break
}
}
if client == nil && len(inst.Clients) > 0 && email == "" {
client = &inst.Clients[0]
}
if client == nil || client.UUID == "" || client.Password == "" {
return ""
}
params := make(map[string]string)
cc := inst.CongestionControl
if cc == "" {
cc = "bbr"
}
params["congestion_control"] = cc
if len(inst.ALPN) > 0 {
params["alpn"] = strings.Join(inst.ALPN, ",")
}
if inst.SNI != "" {
params["sni"] = inst.SNI
}
if inst.UDPRelayMode != "" {
params["udp_relay_mode"] = inst.UDPRelayMode
}
params["allow_insecure"] = "0"
stream := unmarshalStreamSettings(inbound.StreamSettings)
externalProxies, _ := stream["externalProxy"].([]any)
if len(externalProxies) > 0 {
links := make([]string, 0, len(externalProxies))
for _, externalProxy := range externalProxies {
ep, ok := externalProxy.(map[string]any)
if !ok {
continue
}
dest, _ := ep["dest"].(string)
portF, okPort := ep["port"].(float64)
if dest == "" || !okPort {
continue
}
epParams := cloneStringMap(params)
if sni, ok := externalProxySNI(ep); ok {
epParams["sni"] = sni
}
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
epParams["alpn"] = alpn
}
if ai, ok := ep["allowInsecure"].(bool); ok && ai {
epParams["allow_insecure"] = "1"
}
link := fmt.Sprintf("tuic://%s:%s@%s", encodeUserinfo(client.UUID), encodeUserinfo(client.Password), joinHostPort(dest, int(portF)))
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "")))
}
return strings.Join(links, "\n")
}
host := s.resolveInboundAddress(inbound)
link := fmt.Sprintf("tuic://%s:%s@%s", encodeUserinfo(client.UUID), encodeUserinfo(client.Password), joinHostPort(host, inbound.Port))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
// genWireguardLink builds a per-client wireguard:// share link mirroring the
// frontend genWireguardLink: the client's private key is the userinfo, the
// server public key (derived from the inbound secretKey) and the client's
+190
View File
@@ -0,0 +1,190 @@
package sub
import (
"net/url"
"reflect"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestGenTuicLinkBasic(t *testing.T) {
inbound := &model.Inbound{
Listen: "198.51.100.1",
Port: 8443,
Protocol: model.TUIC,
Remark: "tuic-test",
Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key"},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
}
s := &SubService{}
link := s.genTuicLink(inbound, "user@test")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("link does not parse: %v\ngot: %s", err, link)
}
if u.Scheme != "tuic" {
t.Fatalf("scheme = %q, want tuic", u.Scheme)
}
if u.Host != "198.51.100.1:8443" {
t.Fatalf("host = %q, want 198.51.100.1:8443", u.Host)
}
if u.User.Username() != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("username = %q, want uuid", u.User.Username())
}
pass, ok := u.User.Password()
if !ok || pass != "testpassword" {
t.Fatalf("password = %q, want testpassword", pass)
}
q := u.Query()
if q.Get("congestion_control") != "bbr" {
t.Fatalf("congestion_control = %q, want bbr", q.Get("congestion_control"))
}
if q.Get("allow_insecure") != "0" {
t.Fatalf("allow_insecure = %q, want 0", q.Get("allow_insecure"))
}
if q.Get("alpn") != "h3,spdy/3.1" {
t.Fatalf("alpn = %q, want h3,spdy/3.1", q.Get("alpn"))
}
if q.Get("udp_relay_mode") != "native" {
t.Fatalf("udp_relay_mode = %q, want native", q.Get("udp_relay_mode"))
}
if u.Fragment != "tuic-test-user@test" {
t.Fatalf("fragment = %q, want tuic-test-user@test", u.Fragment)
}
}
func TestGenTuicLinkExplicitParams(t *testing.T) {
inbound := &model.Inbound{
Listen: "198.51.100.1",
Port: 8443,
Protocol: model.TUIC,
Remark: "tuic-test",
Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key","congestion_control":"cubic","alpn":["h3"],"sni":"tuic.example.com","udp_relay_mode":"quic"},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
}
s := &SubService{}
link := s.genTuicLink(inbound, "user@test")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("link does not parse: %v\ngot: %s", err, link)
}
q := u.Query()
if q.Get("congestion_control") != "cubic" {
t.Fatalf("congestion_control = %q, want cubic", q.Get("congestion_control"))
}
if q.Get("alpn") != "h3" {
t.Fatalf("alpn = %q, want h3", q.Get("alpn"))
}
if q.Get("sni") != "tuic.example.com" {
t.Fatalf("sni = %q, want tuic.example.com", q.Get("sni"))
}
if q.Get("udp_relay_mode") != "quic" {
t.Fatalf("udp_relay_mode = %q, want quic", q.Get("udp_relay_mode"))
}
}
func TestGenTuicLinkExternalProxyFanOut(t *testing.T) {
inbound := &model.Inbound{
Listen: "0.0.0.0",
Port: 8443,
Protocol: model.TUIC,
Remark: "tuic-base",
Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key"},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
StreamSettings: `{"externalProxy":[{"dest":"host1.example.com","port":9443,"remark":"US"},{"dest":"host2.example.com","port":10443,"remark":"EU","allowInsecure":true}]}`,
}
s := &SubService{}
links := s.genTuicLink(inbound, "user@test")
lines := strings.Split(strings.TrimSpace(links), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 links for externalProxy fan-out, got %d:\n%s", len(lines), links)
}
u1, err := url.Parse(lines[0])
if err != nil {
t.Fatalf("first link does not parse: %v", err)
}
if u1.Host != "host1.example.com:9443" {
t.Fatalf("first host = %q, want host1.example.com:9443", u1.Host)
}
if !strings.Contains(u1.Fragment, "US") {
t.Fatalf("first fragment = %q, want to contain US", u1.Fragment)
}
if u1.Query().Get("allow_insecure") != "0" {
t.Fatalf("first allow_insecure = %q, want 0", u1.Query().Get("allow_insecure"))
}
if u1.Query().Has("allowInsecure") {
t.Fatalf("first link should not have allowInsecure")
}
u2, err := url.Parse(lines[1])
if err != nil {
t.Fatalf("second link does not parse: %v", err)
}
if u2.Host != "host2.example.com:10443" {
t.Fatalf("second host = %q, want host2.example.com:10443", u2.Host)
}
if !strings.Contains(u2.Fragment, "EU") {
t.Fatalf("second fragment = %q, want to contain EU", u2.Fragment)
}
if u2.Query().Get("allow_insecure") != "1" {
t.Fatalf("second allow_insecure = %q, want 1", u2.Query().Get("allow_insecure"))
}
if u2.Query().Has("allowInsecure") {
t.Fatalf("second link should not have allowInsecure")
}
}
func TestBuildTuicProxy_ExternalProxyOverrides(t *testing.T) {
svc := &SubClashService{SubService: &SubService{address: "sub.example.com"}}
inbound := &model.Inbound{
Listen: "198.51.100.1",
Port: 8443,
Protocol: model.TUIC,
Remark: "tuic-base",
Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key","sni":"base.example.com","alpn":["h3"]},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
}
client := model.Client{Email: "user@test"}
// 1. Without overrides from ep
baseProxy := svc.buildTuicProxy(svc.SubService, inbound, client, nil)
if baseProxy == nil {
t.Fatal("baseProxy is nil")
}
if baseProxy["sni"] != "base.example.com" {
t.Fatalf("base sni = %v, want base.example.com", baseProxy["sni"])
}
if !reflect.DeepEqual(baseProxy["alpn"], []string{"h3"}) {
t.Fatalf("base alpn = %v, want [h3]", baseProxy["alpn"])
}
if _, ok := baseProxy["skip-cert-verify"]; ok {
t.Fatalf("base skip-cert-verify should not be set")
}
// 2. With overrides from ep
ep := map[string]any{
"dest": "custom.example.com",
"port": float64(9443),
"remark": "custom-node",
"sni": "custom.sni.com",
"alpn": "h3,spdy/3.1",
"allowInsecure": true,
}
proxy := svc.buildTuicProxy(svc.SubService, inbound, client, ep)
if proxy == nil {
t.Fatal("proxy is nil")
}
if proxy["sni"] != "custom.sni.com" {
t.Fatalf("sni = %v, want custom.sni.com", proxy["sni"])
}
if !reflect.DeepEqual(proxy["alpn"], []string{"h3", "spdy/3.1"}) {
t.Fatalf("alpn = %v, want [h3 spdy/3.1]", proxy["alpn"])
}
if proxy["skip-cert-verify"] != true {
t.Fatalf("skip-cert-verify = %v, want true", proxy["skip-cert-verify"])
}
}
+94
View File
@@ -0,0 +1,94 @@
package tuic
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
type ServerConfig struct {
Server string `json:"server"`
Users map[string]string `json:"users"`
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
CongestionControl string `json:"congestion_control"`
ALPN []string `json:"alpn"`
ZeroRTTHandshake bool `json:"zero_rtt_handshake"`
LogLevel string `json:"log_level"`
MaxIdleTime string `json:"max_idle_time,omitempty"`
AuthTimeout string `json:"auth_timeout,omitempty"`
MaxExternalPacketSize int `json:"max_external_packet_size,omitempty"`
}
// bind is where the sidecar itself listens: a loopback port behind the
// panel's relay, never the inbound's public address (see udpRelay).
func GenerateConfig(inst Instance, bind string) ([]byte, error) {
users := make(map[string]string, len(inst.Clients))
for _, c := range inst.Clients {
if c.UUID != "" && c.Password != "" {
users[c.UUID] = c.Password
}
}
authTimeoutStr := ""
if inst.AuthenticationTimeout > 0 {
authTimeoutStr = fmt.Sprintf("%ds", inst.AuthenticationTimeout)
}
maxIdleStr := ""
if inst.MaxIdleTime > 0 {
maxIdleStr = fmt.Sprintf("%ds", inst.MaxIdleTime)
}
logLevel := inst.LogLevel
if logLevel == "" {
logLevel = "info"
}
cfg := ServerConfig{
Server: bind,
Users: users,
Certificate: inst.Certificate,
PrivateKey: inst.PrivateKey,
CongestionControl: inst.CongestionControl,
ALPN: inst.ALPN,
ZeroRTTHandshake: inst.ZeroRTTHandshake,
LogLevel: logLevel,
AuthTimeout: authTimeoutStr,
MaxIdleTime: maxIdleStr,
MaxExternalPacketSize: inst.MaxUdpRelayPacketSize,
}
return json.MarshalIndent(cfg, "", " ")
}
func ConfigDir() string {
return filepath.Join(config.GetBinFolderPath(), "tuic")
}
func ConfigPathForID(id int) string {
return filepath.Join(ConfigDir(), fmt.Sprintf("tuic_%d.json", id))
}
func WriteConfigFile(id int, data []byte) (string, error) {
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := ConfigPathForID(id)
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
return path, nil
}
func RemoveConfigFile(id int) error {
path := ConfigPathForID(id)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package tuic
import (
"encoding/json"
"testing"
)
func TestGenerateConfig(t *testing.T) {
inst := Instance{
Id: 1,
Port: 8443,
Listen: "0.0.0.0",
Certificate: "/etc/ssl/cert.pem",
PrivateKey: "/etc/ssl/key.pem",
CongestionControl: "bbr",
ALPN: []string{"h3", "spdy/3.1"},
UDPRelayMode: "native",
ZeroRTTHandshake: true,
LogLevel: "info",
MaxIdleTime: 15,
AuthenticationTimeout: 3,
MaxUdpRelayPacketSize: 1500,
Clients: []TuicClientSettings{
{UUID: "uuid-1", Password: "pass-1", Email: "e1"},
{UUID: "uuid-2", Password: "pass-2", Email: "e2"},
},
}
data, err := GenerateConfig(inst, "127.0.0.1:4433")
if err != nil {
t.Fatalf("GenerateConfig error: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if parsed["server"] != "127.0.0.1:4433" {
t.Fatalf("expected the sidecar bound to the relay's loopback port, got %v", parsed["server"])
}
if parsed["certificate"] != "/etc/ssl/cert.pem" || parsed["private_key"] != "/etc/ssl/key.pem" {
t.Fatalf("unexpected cert/key in json: %v", parsed)
}
users, ok := parsed["users"].(map[string]any)
if !ok {
t.Fatalf("expected users map, got %T", parsed["users"])
}
if users["uuid-1"] != "pass-1" || users["uuid-2"] != "pass-2" {
t.Fatalf("unexpected users in json: %v", users)
}
}
func TestGenerateConfigLogLevel(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"info", "info"},
{"warn", "warn"},
{"error", "error"},
{"", "info"},
{"debug", "debug"},
{"trace", "trace"},
}
for _, tc := range tests {
inst := Instance{
Id: 1,
Port: 8443,
Listen: "0.0.0.0",
LogLevel: tc.input,
}
data, err := GenerateConfig(inst, "127.0.0.1:4433")
if err != nil {
t.Fatalf("GenerateConfig error for %s: %v", tc.input, err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Unmarshal error for %s: %v", tc.input, err)
}
if parsed["log_level"] != tc.expected {
t.Fatalf("expected log_level %s for input %s, got %v", tc.expected, tc.input, parsed["log_level"])
}
}
}
+231
View File
@@ -0,0 +1,231 @@
package tuic
import (
"fmt"
"net"
"strconv"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
type managed struct {
proc *Process
relay *udpRelay
tag string
configPath string
structuralFP string
usersFP string
}
type Manager struct {
mu sync.Mutex
procs map[int]*managed
lastStartErr map[int]string
}
var (
managerInstance *Manager
managerOnce sync.Once
)
func GetManager() *Manager {
managerOnce.Do(func() {
managerInstance = &Manager{
procs: make(map[int]*managed),
lastStartErr: make(map[int]string),
}
if n := killStrayTuicProcesses(GetBinaryPath()); n > 0 {
logger.Warningf("tuic: terminated %d orphaned tuic-server process(es) from a previous run", n)
}
})
return managerInstance
}
func (m *Manager) HasRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, mg := range m.procs {
if mg.proc != nil && mg.proc.IsRunning() {
return true
}
}
return false
}
func (m *Manager) Ensure(inst Instance) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.ensureLocked(inst)
}
func (m *Manager) ensureLocked(inst Instance) error {
if len(inst.Clients) == 0 {
m.removeLocked(inst.Id)
return nil
}
structuralFP := inst.StructuralFingerprint()
usersFP := inst.UsersFingerprint()
uuidToEmail := make(map[string]string, len(inst.Clients))
for _, c := range inst.Clients {
if c.UUID != "" && c.Email != "" {
uuidToEmail[c.UUID] = c.Email
}
}
if existing, ok := m.procs[inst.Id]; ok && existing != nil {
if existing.proc != nil && existing.proc.IsRunning() &&
existing.structuralFP == structuralFP && existing.usersFP == usersFP {
existing.tag = inst.Tag
existing.proc.UpdateClients(uuidToEmail)
return nil
}
stopManaged(existing)
delete(m.procs, inst.Id)
}
proc, relay, configPath, err := m.startLocked(inst, uuidToEmail)
if err != nil {
if m.lastStartErr[inst.Id] != err.Error() {
m.lastStartErr[inst.Id] = err.Error()
logger.Warningf("tuic: failed to start tuic-server for inbound %d (%s): %v", inst.Id, inst.Tag, err)
}
return err
}
delete(m.lastStartErr, inst.Id)
m.procs[inst.Id] = &managed{
proc: proc,
relay: relay,
tag: inst.Tag,
configPath: configPath,
structuralFP: structuralFP,
usersFP: usersFP,
}
return nil
}
func (m *Manager) startLocked(inst Instance, uuidToEmail map[string]string) (*Process, *udpRelay, string, error) {
port, err := freeLoopbackUDPPort()
if err != nil {
return nil, nil, "", fmt.Errorf("tuic: pick sidecar port for %d: %w", inst.Id, err)
}
upstream := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
configBytes, err := GenerateConfig(inst, net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
return nil, nil, "", fmt.Errorf("tuic: generate config for %d: %w", inst.Id, err)
}
configPath, err := WriteConfigFile(inst.Id, configBytes)
if err != nil {
return nil, nil, "", fmt.Errorf("tuic: write config for %d: %w", inst.Id, err)
}
relay, err := startUDPRelay(inst.BindTo(), upstream, relayFlowIdle)
if err != nil {
_ = RemoveConfigFile(inst.Id)
return nil, nil, "", fmt.Errorf("tuic: listen on %s for %d: %w", inst.BindTo(), inst.Id, err)
}
proc := newProcess(configPath, inst.Tag, uuidToEmail)
if err := proc.Start(); err != nil {
relay.Close()
_ = RemoveConfigFile(inst.Id)
return nil, nil, "", err
}
return proc, relay, configPath, nil
}
func stopManaged(mg *managed) {
if mg.proc != nil && mg.proc.IsRunning() {
_ = mg.proc.Stop()
}
mg.relay.Close()
}
func (m *Manager) GetActiveClients(window time.Duration) ([]string, []string) {
m.mu.Lock()
defer m.mu.Unlock()
var emails []string
var tags []string
for _, mg := range m.procs {
if mg.proc != nil && mg.proc.IsRunning() {
active := mg.proc.GetActiveEmails(window)
if len(active) > 0 {
emails = append(emails, active...)
tags = append(tags, mg.tag)
}
}
}
return emails, tags
}
type InboundTrafficDelta struct {
Tag string
Up int64
Down int64
}
func (m *Manager) CollectTraffic() []InboundTrafficDelta {
m.mu.Lock()
defer m.mu.Unlock()
var out []InboundTrafficDelta
for _, mg := range m.procs {
if mg.relay != nil && mg.proc != nil && mg.proc.IsRunning() {
deltaUp, deltaDown := mg.relay.CollectTraffic()
if deltaUp > 0 || deltaDown > 0 {
out = append(out, InboundTrafficDelta{
Tag: mg.tag,
Up: deltaUp,
Down: deltaDown,
})
}
}
}
return out
}
func (m *Manager) Remove(id int) {
m.mu.Lock()
defer m.mu.Unlock()
m.removeLocked(id)
}
func (m *Manager) removeLocked(id int) {
if existing, ok := m.procs[id]; ok && existing != nil {
stopManaged(existing)
_ = RemoveConfigFile(id)
delete(m.procs, id)
delete(m.lastStartErr, id)
}
}
func (m *Manager) Reconcile(desired []Instance) {
m.mu.Lock()
defer m.mu.Unlock()
desiredMap := make(map[int]Instance, len(desired))
for _, inst := range desired {
desiredMap[inst.Id] = inst
}
for id := range m.procs {
if _, ok := desiredMap[id]; !ok {
m.removeLocked(id)
}
}
for _, inst := range desired {
_ = m.ensureLocked(inst)
}
}
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, mg := range m.procs {
stopManaged(mg)
_ = RemoveConfigFile(id)
}
m.procs = make(map[int]*managed)
}
+95
View File
@@ -0,0 +1,95 @@
package tuic
import (
"encoding/json"
"net"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestEnsureFrontsSidecarWithRelayAndRemoveReleasesPort(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a shell script as the sidecar binary")
}
bin := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", bin)
if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
t.Fatal(err)
}
port, err := freeLoopbackUDPPort()
if err != nil {
t.Fatal(err)
}
inst := Instance{
Id: 7, Tag: "tuic-7", Listen: "127.0.0.1", Port: port,
Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
}
m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
t.Cleanup(m.StopAll)
if err := m.Ensure(inst); err != nil {
t.Fatalf("Ensure: %v", err)
}
if c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}); err == nil {
_ = c.Close()
t.Fatal("the relay must own the inbound's public port while the sidecar runs")
}
raw, err := os.ReadFile(ConfigPathForID(7))
if err != nil {
t.Fatal(err)
}
var cfg struct {
Server string `json:"server"`
}
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
host, sidecarPort, err := net.SplitHostPort(cfg.Server)
if err != nil || host != "127.0.0.1" || sidecarPort == "" || cfg.Server == inst.BindTo() {
t.Fatalf("sidecar bound to %q, want a loopback port other than the public %q", cfg.Server, inst.BindTo())
}
m.Remove(7)
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
if err != nil {
t.Fatalf("public port still held after Remove: %v", err)
}
_ = c.Close()
}
func TestEnsureUpdatesTagWithoutRestart(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a shell script as the sidecar binary")
}
bin := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", bin)
if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
t.Fatal(err)
}
port, err := freeLoopbackUDPPort()
if err != nil {
t.Fatal(err)
}
inst := Instance{
Id: 8, Tag: "old-tag", Listen: "127.0.0.1", Port: port,
Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
}
m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
t.Cleanup(m.StopAll)
if err := m.Ensure(inst); err != nil {
t.Fatalf("Ensure: %v", err)
}
inst.Tag = "new-tag"
if err := m.Ensure(inst); err != nil {
t.Fatalf("Ensure updated tag: %v", err)
}
m.mu.Lock()
gotTag := m.procs[8].tag
m.mu.Unlock()
if gotTag != "new-tag" {
t.Fatalf("manager tag = %q, want %q", gotTag, "new-tag")
}
}
+87
View File
@@ -0,0 +1,87 @@
//go:build linux
package tuic
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
func killStrayTuicProcesses(binaryPath string) int {
base := filepath.Base(binaryPath)
if base == "" || base == "." || base == string(filepath.Separator) {
return 0
}
configDir := filepath.Clean(ConfigDir())
self := os.Getpid()
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
killed := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self {
continue
}
if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
continue
}
if !isManagedTuicCmdline(pid, configDir) {
continue
}
if err := syscall.Kill(pid, syscall.SIGTERM); err == nil {
killed++
time.Sleep(50 * time.Millisecond)
if err := syscall.Kill(pid, 0); err == nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
}
return killed
}
func isManagedTuicCmdline(pid int, configDir string) bool {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil || len(data) == 0 {
return false
}
args := strings.Split(string(data), "\x00")
for i, arg := range args {
if arg == "-c" && i+1 < len(args) {
cfg := filepath.Clean(args[i+1])
if strings.HasPrefix(cfg, configDir) {
return true
}
}
}
return false
}
func procExeBase(pid int) string {
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
if err != nil {
return ""
}
return filepath.Base(exe)
}
func cmdlineArgv0Base(pid int) string {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil || len(data) == 0 {
return ""
}
first := data
for i, b := range data {
if b == 0 {
first = data[:i]
break
}
}
return filepath.Base(string(first))
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !linux
package tuic
func killStrayTuicProcesses(_ string) int { return 0 }
+292
View File
@@ -0,0 +1,292 @@
package tuic
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
func GetBinaryName() string {
name := fmt.Sprintf("tuic-server-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
func GetBinaryPath() string {
custom := filepath.Join(config.GetBinFolderPath(), GetBinaryName())
if _, err := os.Stat(custom); err == nil {
return custom
}
binTuic := filepath.Join(config.GetBinFolderPath(), "tuic-server")
if runtime.GOOS == "windows" {
binTuic += ".exe"
}
if _, err := os.Stat(binTuic); err == nil {
return binTuic
}
for _, p := range []string{"/usr/local/bin/tuic-server", "/usr/bin/tuic-server"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
if path, err := exec.LookPath("tuic-server"); err == nil {
return path
}
return binTuic
}
var (
gracefulStopTimeout = 5 * time.Second
forceStopTimeout = 2 * time.Second
)
type procLogWriter struct {
mu sync.Mutex
label string
buf string
lastLine string
uuidToEmail map[string]string
lastActive map[string]int64
}
func (w *procLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf += string(p)
for {
i := strings.IndexByte(w.buf, '\n')
if i < 0 {
break
}
line := w.buf[:i]
w.buf = w.buf[i+1:]
w.emitLocked(line)
}
return len(p), nil
}
func (w *procLogWriter) Flush() {
w.mu.Lock()
defer w.mu.Unlock()
if w.buf != "" {
line := w.buf
w.buf = ""
w.emitLocked(line)
}
}
func (w *procLogWriter) emitLocked(line string) {
trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
if trimmed == "" {
return
}
w.lastLine = trimmed
logger.Infof("tuic: tuic-server %s | %s", w.label, trimmed)
now := time.Now().UnixMilli()
lowerLine := strings.ToLower(line)
for uuid, email := range w.uuidToEmail {
if strings.Contains(lowerLine, uuid) {
if w.lastActive == nil {
w.lastActive = make(map[string]int64)
}
w.lastActive[email] = now
}
}
}
func (w *procLogWriter) LastLine() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.lastLine
}
type Process struct {
mu sync.RWMutex
cmd *exec.Cmd
done chan struct{}
configPath string
logWriter *procLogWriter
exitErr error
intentionalStop atomic.Bool
}
func newProcess(configPath, label string, uuidToEmail map[string]string) *Process {
return &Process{
configPath: configPath,
logWriter: &procLogWriter{
label: label,
uuidToEmail: uuidToEmail,
lastActive: make(map[string]int64),
},
}
}
func (p *Process) GetActiveEmails(window time.Duration) []string {
if p == nil || p.logWriter == nil {
return nil
}
p.logWriter.mu.Lock()
defer p.logWriter.mu.Unlock()
cutoff := time.Now().Add(-window).UnixMilli()
var active []string
for email, last := range p.logWriter.lastActive {
if last >= cutoff {
active = append(active, email)
}
}
return active
}
func (p *Process) UpdateClients(uuidToEmail map[string]string) {
if p == nil || p.logWriter == nil {
return
}
p.logWriter.mu.Lock()
defer p.logWriter.mu.Unlock()
p.logWriter.uuidToEmail = uuidToEmail
}
func (p *Process) IsRunning() bool {
p.mu.RLock()
cmd, done := p.cmd, p.done
p.mu.RUnlock()
if cmd == nil || cmd.Process == nil {
return false
}
if done != nil {
select {
case <-done:
return false
default:
}
}
return true
}
func (p *Process) GetResult() string {
if line := p.logWriter.LastLine(); line != "" {
return line
}
p.mu.RLock()
exitErr := p.exitErr
p.mu.RUnlock()
if exitErr != nil {
return exitErr.Error()
}
return ""
}
func (p *Process) Start() error {
if p.IsRunning() {
return errors.New("tuic-server is already running")
}
cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "-c", p.configPath)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
done := make(chan struct{})
p.mu.Lock()
p.cmd = cmd
p.done = done
p.exitErr = nil
p.mu.Unlock()
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(done)
p.mu.Lock()
p.cmd = nil
p.mu.Unlock()
return err
}
attachChildLifetime(cmd)
go p.wait(cmd, done)
return nil
}
func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
defer close(done)
err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() {
return
}
if runtime.GOOS == "windows" {
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
p.setExitErr(err)
return
}
}
logger.Errorf("tuic: tuic-server process exited: %v", err)
p.setExitErr(err)
}
func (p *Process) setExitErr(err error) {
p.mu.Lock()
p.exitErr = err
p.mu.Unlock()
}
func (p *Process) Stop() error {
if !p.IsRunning() {
return errors.New("tuic-server is not running")
}
p.intentionalStop.Store(true)
p.mu.RLock()
cmd, done := p.cmd, p.done
p.mu.RUnlock()
if cmd == nil || cmd.Process == nil {
return errors.New("tuic-server is not running")
}
if runtime.GOOS == "windows" {
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return waitForExit(done, forceStopTimeout)
}
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return waitForExit(done, forceStopTimeout)
}
return err
}
if err := waitForExit(done, gracefulStopTimeout); err == nil {
return nil
}
logger.Warning("tuic: tuic-server did not stop after SIGTERM, killing process")
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return waitForExit(done, forceStopTimeout)
}
func waitForExit(done <-chan struct{}, timeout time.Duration) error {
if done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
return nil
case <-timer.C:
return fmt.Errorf("timed out waiting for tuic-server process to stop after %s", timeout)
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package tuic
import "os/exec"
func attachChildLifetime(_ *exec.Cmd) {}
+67
View File
@@ -0,0 +1,67 @@
//go:build windows
package tuic
import (
"os/exec"
"sync"
"unsafe"
"golang.org/x/sys/windows"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
var (
killOnExitJobOnce sync.Once
killOnExitJob windows.Handle
killOnExitJobErr error
)
func ensureKillOnExitJob() (windows.Handle, error) {
killOnExitJobOnce.Do(func() {
h, err := windows.CreateJobObject(nil, nil)
if err != nil {
killOnExitJobErr = err
return
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
_, err = windows.SetInformationJobObject(
h,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
if err != nil {
_ = windows.CloseHandle(h)
killOnExitJobErr = err
return
}
killOnExitJob = h
})
return killOnExitJob, killOnExitJobErr
}
func attachChildLifetime(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
job, err := ensureKillOnExitJob()
if err != nil {
logger.Warningf("tuic: kill-on-exit job unavailable: %v", err)
return
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err != nil {
logger.Warningf("tuic: OpenProcess for job attach failed: %v", err)
return
}
defer func() { _ = windows.CloseHandle(h) }()
if err := windows.AssignProcessToJobObject(job, h); err != nil {
logger.Warningf("tuic: AssignProcessToJobObject failed: %v", err)
}
}
+196
View File
@@ -0,0 +1,196 @@
package tuic
import (
"errors"
"net"
"sync"
"sync/atomic"
"time"
)
// A QUIC flow the sidecar has not touched for this long is forgotten; QUIC's
// own max_idle_time (15s by default) closes the session well before that.
const relayFlowIdle = 2 * time.Minute
const (
relaySocketBuffer = 4 << 20
maxRelayFlows = 4096
)
// udpRelay owns an inbound's public UDP port and forwards each client's
// datagrams to the sidecar on loopback, which is the only place the panel can
// count the inbound's bytes: upstream tuic-server exposes no stats API and
// its socket syscalls never reach /proc/<pid>/io. Per-client attribution stays
// impossible because QUIC payloads are opaque.
type udpRelay struct {
public *net.UDPConn
upstream *net.UDPAddr
idle time.Duration
up atomic.Int64
down atomic.Int64
mu sync.Mutex
flows map[string]*relayFlow
done chan struct{}
closeOnce sync.Once
wg sync.WaitGroup
}
type relayFlow struct {
conn *net.UDPConn
client *net.UDPAddr
lastSeen atomic.Int64
}
func startUDPRelay(bind string, upstream *net.UDPAddr, idle time.Duration) (*udpRelay, error) {
addr, err := net.ResolveUDPAddr("udp", bind)
if err != nil {
return nil, err
}
public, err := net.ListenUDP("udp", addr)
if err != nil {
return nil, err
}
_ = public.SetReadBuffer(relaySocketBuffer)
_ = public.SetWriteBuffer(relaySocketBuffer)
r := &udpRelay{
public: public,
upstream: upstream,
idle: idle,
flows: make(map[string]*relayFlow),
done: make(chan struct{}),
}
r.wg.Add(2)
go r.serve()
go r.sweep()
return r, nil
}
func freeLoopbackUDPPort() (int, error) {
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
return 0, err
}
defer c.Close()
return c.LocalAddr().(*net.UDPAddr).Port, nil
}
func (r *udpRelay) LocalAddr() net.Addr {
return r.public.LocalAddr()
}
// CollectTraffic returns the client-to-sidecar and sidecar-to-client bytes
// relayed since the previous call.
func (r *udpRelay) CollectTraffic() (up, down int64) {
return r.up.Swap(0), r.down.Swap(0)
}
func (r *udpRelay) Close() {
if r == nil {
return
}
r.closeOnce.Do(func() {
close(r.done)
_ = r.public.Close()
r.mu.Lock()
for key, f := range r.flows {
_ = f.conn.Close()
delete(r.flows, key)
}
r.mu.Unlock()
r.wg.Wait()
})
}
func (r *udpRelay) serve() {
defer r.wg.Done()
buf := make([]byte, 65535)
for {
n, client, err := r.public.ReadFromUDP(buf)
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
flow, err := r.flowFor(client)
if err != nil {
continue
}
if _, err := flow.conn.Write(buf[:n]); err == nil {
r.up.Add(int64(n))
}
}
}
func (r *udpRelay) flowFor(client *net.UDPAddr) (*relayFlow, error) {
key := client.String()
now := time.Now().UnixMilli()
r.mu.Lock()
defer r.mu.Unlock()
select {
case <-r.done:
return nil, net.ErrClosed
default:
}
if f, ok := r.flows[key]; ok {
f.lastSeen.Store(now)
return f, nil
}
if len(r.flows) >= maxRelayFlows {
return nil, errors.New("tuic: max relay flows reached")
}
conn, err := net.DialUDP("udp", nil, r.upstream)
if err != nil {
return nil, err
}
_ = conn.SetReadBuffer(relaySocketBuffer)
_ = conn.SetWriteBuffer(relaySocketBuffer)
f := &relayFlow{conn: conn, client: client}
f.lastSeen.Store(now)
r.flows[key] = f
r.wg.Add(1)
go r.pump(f)
return f, nil
}
func (r *udpRelay) pump(f *relayFlow) {
defer r.wg.Done()
buf := make([]byte, 65535)
for {
n, err := f.conn.Read(buf)
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
// ICMP unreachable while the sidecar restarts: drop it, keep the flow.
time.Sleep(20 * time.Millisecond)
continue
}
if _, err := r.public.WriteToUDP(buf[:n], f.client); err == nil {
r.down.Add(int64(n))
}
f.lastSeen.Store(time.Now().UnixMilli())
}
}
func (r *udpRelay) sweep() {
defer r.wg.Done()
ticker := time.NewTicker(r.idle / 2)
defer ticker.Stop()
for {
select {
case <-r.done:
return
case <-ticker.C:
cutoff := time.Now().Add(-r.idle).UnixMilli()
r.mu.Lock()
for key, f := range r.flows {
if f.lastSeen.Load() < cutoff {
_ = f.conn.Close()
delete(r.flows, key)
}
}
r.mu.Unlock()
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package tuic
import (
"bytes"
"net"
"testing"
"time"
)
// doublingEcho answers every datagram with the payload repeated twice, so a
// relay that mislabels directions or clients cannot pass by accident.
func doublingEcho(t *testing.T) *net.UDPAddr {
t.Helper()
echo, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = echo.Close() })
go func() {
buf := make([]byte, 65535)
for {
n, from, err := echo.ReadFromUDP(buf)
if err != nil {
return
}
_, _ = echo.WriteToUDP(append(append([]byte{}, buf[:n]...), buf[:n]...), from)
}
}()
return echo.LocalAddr().(*net.UDPAddr)
}
func roundTrip(t *testing.T, relay *udpRelay, payload []byte) int {
t.Helper()
c, err := net.DialUDP("udp", nil, relay.LocalAddr().(*net.UDPAddr))
if err != nil {
t.Fatal(err)
}
defer c.Close()
if _, err := c.Write(payload); err != nil {
t.Fatal(err)
}
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 65535)
n, err := c.Read(buf)
if err != nil {
t.Fatalf("no reply through the relay: %v", err)
}
return n
}
func collectUntil(t *testing.T, relay *udpRelay, wantUp, wantDown int64) (int64, int64) {
t.Helper()
var up, down int64
deadline := time.Now().Add(2 * time.Second)
for {
u, d := relay.CollectTraffic()
up, down = up+u, down+d
if (up >= wantUp && down >= wantDown) || time.Now().After(deadline) {
return up, down
}
time.Sleep(10 * time.Millisecond)
}
}
func TestUDPRelayMetersBothDirectionsPerClient(t *testing.T) {
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
if err != nil {
t.Fatal(err)
}
t.Cleanup(relay.Close)
if got := roundTrip(t, relay, bytes.Repeat([]byte("a"), 100)); got != 200 {
t.Fatalf("client A reply = %d bytes, want 200", got)
}
if got := roundTrip(t, relay, bytes.Repeat([]byte("b"), 50)); got != 100 {
t.Fatalf("client B reply = %d bytes, want 100", got)
}
if up, down := collectUntil(t, relay, 150, 300); up != 150 || down != 300 {
t.Fatalf("delta = (%d up, %d down), want (150, 300)", up, down)
}
if up, down := relay.CollectTraffic(); up != 0 || down != 0 {
t.Fatalf("second collect = (%d, %d), want (0, 0): deltas must reset", up, down)
}
}
func TestUDPRelayExpiresIdleFlows(t *testing.T) {
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), 50*time.Millisecond)
if err != nil {
t.Fatal(err)
}
t.Cleanup(relay.Close)
roundTrip(t, relay, []byte("hello"))
deadline := time.Now().Add(2 * time.Second)
for {
relay.mu.Lock()
n := len(relay.flows)
relay.mu.Unlock()
if n == 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("%d flow(s) still open after the idle window", n)
}
time.Sleep(10 * time.Millisecond)
}
if got := roundTrip(t, relay, []byte("again")); got != 10 {
t.Fatalf("reply after expiry = %d bytes, want 10", got)
}
}
func TestUDPRelayRefusesFlowsAfterClose(t *testing.T) {
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
if err != nil {
t.Fatal(err)
}
relay.Close()
if _, err := relay.flowFor(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9}); err == nil {
t.Fatal("flowFor after Close must refuse: its pump would outlive the relay and hang Close's WaitGroup")
}
relay.mu.Lock()
n := len(relay.flows)
relay.mu.Unlock()
if n != 0 {
t.Fatalf("%d flow(s) registered after Close", n)
}
}
+249
View File
@@ -0,0 +1,249 @@
package tuic
import (
"encoding/json"
"fmt"
"net"
"slices"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type TuicServerSettings struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
CongestionControl string `json:"congestion_control"`
ALPN []string `json:"alpn"`
UDPRelayMode string `json:"udp_relay_mode"`
ZeroRTTHandshake bool `json:"zero_rtt_handshake"`
LogLevel string `json:"log_level"`
MaxIdleTime int `json:"max_idle_time"`
AuthenticationTimeout int `json:"authentication_timeout"`
MaxUdpRelayPacketSize int `json:"max_udp_relay_packet_size"`
SNI string `json:"sni,omitempty"`
}
type TuicClientSettings struct {
UUID string `json:"uuid"`
Password string `json:"password"`
Email string `json:"email"`
}
type Instance struct {
Id int
Tag string
Listen string
Port int
Certificate string
PrivateKey string
CongestionControl string
ALPN []string
UDPRelayMode string
ZeroRTTHandshake bool
LogLevel string
MaxIdleTime int
AuthenticationTimeout int
MaxUdpRelayPacketSize int
SNI string
Clients []TuicClientSettings
}
func (inst Instance) BindTo() string {
listen := inst.Listen
if listen == "" {
listen = "0.0.0.0"
}
return net.JoinHostPort(listen, strconv.Itoa(inst.Port))
}
func (inst Instance) StructuralFingerprint() string {
parts := []string{
inst.BindTo(),
inst.Certificate,
inst.PrivateKey,
inst.CongestionControl,
strings.Join(inst.ALPN, ","),
inst.UDPRelayMode,
strconv.FormatBool(inst.ZeroRTTHandshake),
inst.LogLevel,
strconv.Itoa(inst.MaxIdleTime),
strconv.Itoa(inst.AuthenticationTimeout),
strconv.Itoa(inst.MaxUdpRelayPacketSize),
inst.SNI,
}
return strings.Join(parts, "|")
}
func (inst Instance) UsersFingerprint() string {
pairs := make([]string, 0, len(inst.Clients))
for _, c := range inst.Clients {
pairs = append(pairs, fmt.Sprintf("%s=%s:%s", c.Email, c.UUID, c.Password))
}
slices.Sort(pairs)
return strings.Join(pairs, "|")
}
func (inst Instance) FullFingerprint() string {
return inst.StructuralFingerprint() + "#" + inst.UsersFingerprint()
}
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.TUIC {
return Instance{}, false
}
var parsed struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
CongestionControl string `json:"congestion_control"`
ALPN []string `json:"alpn"`
UDPRelayMode string `json:"udp_relay_mode"`
ZeroRTTHandshake *bool `json:"zero_rtt_handshake"`
LogLevel string `json:"log_level"`
MaxIdleTime int `json:"max_idle_time"`
AuthenticationTimeout int `json:"authentication_timeout"`
MaxUdpRelayPacketSize int `json:"max_udp_relay_packet_size"`
SNI string `json:"sni"`
Server *struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
CongestionControl string `json:"congestion_control"`
ALPN []string `json:"alpn"`
UDPRelayMode string `json:"udp_relay_mode"`
ZeroRTTHandshake *bool `json:"zero_rtt_handshake"`
LogLevel string `json:"log_level"`
MaxIdleTime int `json:"max_idle_time"`
AuthenticationTimeout int `json:"authentication_timeout"`
MaxUdpRelayPacketSize int `json:"max_udp_relay_packet_size"`
SNI string `json:"sni"`
} `json:"server"`
Clients []struct {
UUID string `json:"uuid"`
ID string `json:"id"`
Password string `json:"password"`
Email string `json:"email"`
Enable *bool `json:"enable"`
TotalGB int64 `json:"totalGB"`
ExpiryTime int64 `json:"expiryTime"`
} `json:"clients"`
}
if ib.Settings != "" {
if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
return Instance{}, false
}
}
cert := parsed.Certificate
key := parsed.PrivateKey
cc := parsed.CongestionControl
alpn := parsed.ALPN
udpRelayMode := parsed.UDPRelayMode
zeroRtt := true
if parsed.ZeroRTTHandshake != nil {
zeroRtt = *parsed.ZeroRTTHandshake
}
logLevel := parsed.LogLevel
maxIdle := parsed.MaxIdleTime
authTimeout := parsed.AuthenticationTimeout
maxPacketSize := parsed.MaxUdpRelayPacketSize
sni := parsed.SNI
if parsed.Server != nil {
if parsed.Server.Certificate != "" {
cert = parsed.Server.Certificate
}
if parsed.Server.PrivateKey != "" {
key = parsed.Server.PrivateKey
}
if parsed.Server.CongestionControl != "" {
cc = parsed.Server.CongestionControl
}
if len(parsed.Server.ALPN) > 0 {
alpn = parsed.Server.ALPN
}
if parsed.Server.UDPRelayMode != "" {
udpRelayMode = parsed.Server.UDPRelayMode
}
if parsed.Server.ZeroRTTHandshake != nil {
zeroRtt = *parsed.Server.ZeroRTTHandshake
}
if parsed.Server.LogLevel != "" {
logLevel = parsed.Server.LogLevel
}
if parsed.Server.MaxIdleTime > 0 {
maxIdle = parsed.Server.MaxIdleTime
}
if parsed.Server.AuthenticationTimeout > 0 {
authTimeout = parsed.Server.AuthenticationTimeout
}
if parsed.Server.MaxUdpRelayPacketSize > 0 {
maxPacketSize = parsed.Server.MaxUdpRelayPacketSize
}
if parsed.Server.SNI != "" {
sni = parsed.Server.SNI
}
}
if cc == "" {
cc = "bbr"
}
if len(alpn) == 0 {
alpn = []string{"h3", "spdy/3.1"}
}
if udpRelayMode == "" {
udpRelayMode = "native"
}
if logLevel == "" {
logLevel = "info"
}
if maxIdle <= 0 {
maxIdle = 15
}
if authTimeout <= 0 {
authTimeout = 3
}
if maxPacketSize <= 0 {
maxPacketSize = 1500
}
clients := make([]TuicClientSettings, 0, len(parsed.Clients))
for _, c := range parsed.Clients {
if c.Enable != nil && !*c.Enable {
continue
}
uuidVal := strings.ToLower(strings.TrimSpace(c.UUID))
if uuidVal == "" {
uuidVal = strings.ToLower(strings.TrimSpace(c.ID))
}
if uuidVal == "" || c.Password == "" {
continue
}
clients = append(clients, TuicClientSettings{
UUID: uuidVal,
Password: c.Password,
Email: c.Email,
})
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Certificate: cert,
PrivateKey: key,
CongestionControl: cc,
ALPN: alpn,
UDPRelayMode: udpRelayMode,
ZeroRTTHandshake: zeroRtt,
LogLevel: logLevel,
MaxIdleTime: maxIdle,
AuthenticationTimeout: authTimeout,
MaxUdpRelayPacketSize: maxPacketSize,
SNI: sni,
Clients: clients,
}, true
}
+124
View File
@@ -0,0 +1,124 @@
package tuic
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestInstanceFromInbound(t *testing.T) {
t.Run("valid settings", func(t *testing.T) {
ib := &model.Inbound{
Id: 10,
Tag: "tuic-in-1",
Port: 8443,
Listen: "0.0.0.0",
Protocol: model.TUIC,
Settings: `{"certificate":"/etc/cert.pem","private_key":"/etc/key.pem","congestion_control":"bbr","alpn":["h3"],"clients":[{"uuid":"11111111-2222-3333-4444-555555555555","password":"pass1","email":"user1@test","enable":true}]}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected ok to be true")
}
if inst.Id != 10 || inst.Port != 8443 || inst.Tag != "tuic-in-1" {
t.Fatalf("unexpected inst header fields: %+v", inst)
}
if inst.Certificate != "/etc/cert.pem" || inst.PrivateKey != "/etc/key.pem" {
t.Fatalf("unexpected cert/key: %s / %s", inst.Certificate, inst.PrivateKey)
}
if len(inst.Clients) != 1 {
t.Fatalf("expected 1 client, got %d", len(inst.Clients))
}
if inst.Clients[0].UUID != "11111111-2222-3333-4444-555555555555" || inst.Clients[0].Password != "pass1" {
t.Fatalf("unexpected client: %+v", inst.Clients[0])
}
})
t.Run("normalizes uuid to lowercase and trims space", func(t *testing.T) {
ib := &model.Inbound{
Id: 12,
Protocol: model.TUIC,
Settings: `{"clients":[{"uuid":" A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D ","password":"p"}]}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok || len(inst.Clients) != 1 {
t.Fatal("expected ok and 1 client")
}
if inst.Clients[0].UUID != "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d" {
t.Fatalf("expected lowercase trimmed UUID, got %q", inst.Clients[0].UUID)
}
})
t.Run("nil or wrong protocol", func(t *testing.T) {
if _, ok := InstanceFromInbound(nil); ok {
t.Fatal("expected false for nil")
}
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
t.Fatal("expected false for vless")
}
})
t.Run("no enabled clients", func(t *testing.T) {
ib := &model.Inbound{
Id: 11,
Protocol: model.TUIC,
Settings: `{"clients":[{"uuid":"1111","password":"p","enable":false}]}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected ok for inbound")
}
if len(inst.Clients) != 0 {
t.Fatalf("expected 0 enabled clients, got %d", len(inst.Clients))
}
})
}
func TestFingerprints(t *testing.T) {
inst1 := Instance{
Id: 1,
Port: 8443,
Certificate: "/path/cert",
PrivateKey: "/path/key",
CongestionControl: "bbr",
Clients: []TuicClientSettings{
{UUID: "u1", Password: "p1", Email: "e1"},
{UUID: "u2", Password: "p2", Email: "e2"},
},
}
inst2 := Instance{
Id: 1,
Port: 8443,
Certificate: "/path/cert",
PrivateKey: "/path/key",
CongestionControl: "bbr",
Clients: []TuicClientSettings{
{UUID: "u2", Password: "p2", Email: "e2"},
{UUID: "u1", Password: "p1", Email: "e1"},
},
}
if inst1.UsersFingerprint() != inst2.UsersFingerprint() {
t.Fatalf("users fingerprint must be stable under reordering: %s vs %s", inst1.UsersFingerprint(), inst2.UsersFingerprint())
}
}
func TestBindTo(t *testing.T) {
tests := []struct {
listen string
want string
}{
{"", "0.0.0.0:8443"},
{"127.0.0.1", "127.0.0.1:8443"},
{"::", "[::]:8443"},
{"2001:db8::1", "[2001:db8::1]:8443"},
}
for _, tc := range tests {
t.Run(tc.listen, func(t *testing.T) {
got := Instance{Listen: tc.listen, Port: 8443}.BindTo()
if got != tc.want {
t.Fatalf("BindTo(%q) = %q, want %q", tc.listen, got, tc.want)
}
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package job
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type TuicJob struct {
inboundService service.InboundService
}
func NewTuicJob() *TuicJob {
return new(TuicJob)
}
func (j *TuicJob) Run() {
desired, err := j.inboundService.DesiredTuicInstances()
if err != nil {
logger.Warning("tuic job: get desired instances failed:", err)
return
}
activeTags := make([]string, 0, len(desired))
for _, inst := range desired {
activeTags = append(activeTags, inst.Tag)
}
mgr := tuic.GetManager()
mgr.Reconcile(desired)
deltas := mgr.CollectTraffic()
onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
inboundUp := make(map[string]int64)
inboundDown := make(map[string]int64)
for _, d := range deltas {
inboundUp[d.Tag] += d.Up
inboundDown[d.Tag] += d.Down
}
traffics := make([]*xray.Traffic, 0, len(inboundUp))
for tag, up := range inboundUp {
traffics = append(traffics, &xray.Traffic{
IsInbound: true,
Tag: tag,
Up: up,
Down: inboundDown[tag],
})
}
// Build zero-byte client traffic entries for active clients so adjustTraffics can
// activate delayed-start expiryTime for TUIC clients without inflating traffic.
clientTraffics := make([]*xray.ClientTraffic, 0, len(onlineEmails))
for _, email := range onlineEmails {
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: email,
Up: 0,
Down: 0,
})
}
if len(traffics) > 0 || len(clientTraffics) > 0 {
needRestart, _, err := j.inboundService.AddTraffic(traffics, clientTraffics)
if err != nil {
logger.Warning("tuic job: add traffic failed:", err)
} else if needRestart {
if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
mgr.Reconcile(desired)
}
}
}
if len(onlineEmails) > 0 {
if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
logger.Warning("tuic job: bump last online for tuic clients failed:", err)
}
}
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
}
+40 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
@@ -84,6 +85,13 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
}
return err
}
if ib.Protocol == model.TUIC {
inst, ok := tuic.InstanceFromInbound(ib)
if !ok {
return nil
}
return tuic.GetManager().Ensure(inst)
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
@@ -108,6 +116,10 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
}
return nil
}
if ib.Protocol == model.TUIC {
tuic.GetManager().Remove(ib.Id)
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
@@ -120,6 +132,9 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG {
return l.updateAmneziaWGInbound(ctx, oldIb, newIb)
}
if oldIb.Protocol == model.TUIC || newIb.Protocol == model.TUIC {
return l.updateTuicInbound(ctx, oldIb, newIb)
}
_ = l.DelInbound(ctx, oldIb)
if !newIb.Enable {
return nil
@@ -210,8 +225,31 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
})
}
func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
if oldIb.Protocol == model.TUIC && newIb.Protocol != model.TUIC {
tuic.GetManager().Remove(oldIb.Id)
if !newIb.Enable {
return nil
}
return l.AddInbound(ctx, newIb)
}
if oldIb.Protocol != model.TUIC {
_ = l.DelInbound(ctx, oldIb)
}
if !newIb.Enable {
tuic.GetManager().Remove(newIb.Id)
return nil
}
inst, ok := tuic.InstanceFromInbound(newIb)
if !ok {
tuic.GetManager().Remove(newIb.Id)
return nil
}
return tuic.GetManager().Ensure(inst)
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
@@ -220,7 +258,7 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
+7
View File
@@ -408,6 +408,13 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
if c.Secret == "" {
c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
}
case model.TUIC:
if c.ID == "" {
c.ID = uuid.NewString()
}
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
return nil
}
@@ -451,6 +451,16 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
if client.AdTag != "" && !model.ValidMtprotoAdTag(client.AdTag) {
return false, common.NewError("mtproto client ad tag must be 32 hex characters")
}
case "tuic":
if client.ID == "" {
return false, common.NewError("empty client ID")
}
if client.Password == "" {
return false, common.NewError("tuic client requires a password")
}
if client.Email == "" {
return false, common.NewError("empty client email")
}
default:
if client.ID == "" {
return false, common.NewError("empty client ID")
@@ -563,6 +573,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
inboundSvc.applyLocalMtproto(oldInbound.Id)
} else if oldInbound.Protocol == model.AmneziaWG {
inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else if oldInbound.Protocol == model.TUIC {
inboundSvc.applyLocalTuic(oldInbound.Id)
} else {
for _, client := range clients {
if len(client.Email) == 0 {
@@ -986,6 +998,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
inboundSvc.applyLocalMtproto(oldInbound.Id)
} else if oldInbound.Protocol == model.AmneziaWG {
inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else if oldInbound.Protocol == model.TUIC {
inboundSvc.applyLocalTuic(oldInbound.Id)
} else {
if oldClients[clientIndex].Enable {
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
@@ -1170,6 +1184,8 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
// Same reasoning as MTProto above: the interface config is
// regenerated from the full peer set, so any delete re-applies it.
inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else if oldInbound.Protocol == model.TUIC {
inboundSvc.applyLocalTuic(oldInbound.Id)
} else if needApiDel {
// Local inbound: a disabled client isn't in the running Xray, so only
// a live one (needApiDel) needs an API removal.
+50 -6
View File
@@ -22,6 +22,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -345,7 +346,8 @@ type InboundOption struct {
// AwgServer carries the full AmneziaWG server block (keys, subnet,
// obfuscation params) so the clients page can render a downloadable
// per-client .conf without a second round trip.
AwgServer *amneziawg.ServerSettings `json:"awgServer,omitempty"`
AwgServer *amneziawg.ServerSettings `json:"awgServer,omitempty"`
TuicServer *tuic.TuicServerSettings `json:"tuicServer,omitempty"`
// Hosting node; nil for this panel's own inbounds. Lets the clients
// page map a node filter onto inbound IDs (#4997).
NodeId *int `json:"nodeId,omitempty"`
@@ -412,6 +414,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
WgDns: wgDns,
MtprotoDomain: inboundMtprotoDomain(r.Protocol, r.Settings),
AwgServer: inboundAmneziaWGServer(r.Protocol, r.Settings),
TuicServer: inboundTuicServer(r.Protocol, r.Settings),
NodeId: r.NodeId,
NodeAddress: r.NodeAddress,
Listen: r.Listen,
@@ -506,6 +509,21 @@ func inboundAmneziaWGServer(protocol string, settings string) *amneziawg.ServerS
return &redacted
}
func inboundTuicServer(protocol string, settings string) *tuic.TuicServerSettings {
if protocol != string(model.TUIC) || strings.TrimSpace(settings) == "" {
return nil
}
var parsed struct {
Server *tuic.TuicServerSettings `json:"server"`
}
if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
return nil
}
redacted := *parsed.Server
redacted.PrivateKey = ""
return &redacted
}
// inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
// the clients UI to seed a new mtproto client's secret with the right fronting
// hostname.
@@ -1181,6 +1199,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if client.AdTag != "" && !model.ValidMtprotoAdTag(client.AdTag) {
return inbound, false, common.NewError("mtproto client ad tag must be 32 hex characters")
}
case "tuic":
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
}
if client.Password == "" {
return inbound, false, common.NewError("tuic client requires a password")
}
if client.Email == "" {
return inbound, false, common.NewError("empty client email")
}
default:
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
@@ -1271,7 +1299,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if push {
payload := inbound
pushable := true
if inbound.Protocol == model.MTProto {
if inbound.Protocol == model.MTProto || inbound.Protocol == model.TUIC {
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
payload = built
} else {
@@ -1285,7 +1313,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
} else {
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
needRestart = true
if inbound.Protocol != model.MTProto && inbound.Protocol != model.TUIC {
needRestart = true
}
}
}
}
@@ -1629,6 +1659,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
}
}
}
if inbound.Protocol == model.TUIC {
for _, client := range clients {
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
}
if client.Password == "" {
return inbound, false, common.NewError("tuic client requires a password")
}
if client.Email == "" {
return inbound, false, common.NewError("empty client email")
}
}
}
// Grandfather a row that was already stored incomplete so it stays editable;
// only a save that breaks a previously valid TLS block is refused.
if !s.FromNodeSync {
@@ -1801,7 +1845,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
}
if !push {
needRestart = true
} else if oldProtocol == model.MTProto || oldInbound.Protocol == model.MTProto {
} else if oldProtocol == model.MTProto || oldInbound.Protocol == model.MTProto || oldProtocol == model.TUIC || oldInbound.Protocol == model.TUIC {
oldSnapshot := *oldInbound
oldSnapshot.Tag = tag
oldSnapshot.Protocol = oldProtocol
@@ -1815,14 +1859,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
pushable = false
}
}
newProtocolIsMtproto := oldInbound.Protocol == model.MTProto
newProtocolIsSidecar := oldInbound.Protocol == model.MTProto || oldInbound.Protocol == model.TUIC
if pushable {
postCommitApply = func() {
if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
} else {
logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
if !newProtocolIsMtproto {
if !newProtocolIsSidecar {
needRestart = true
}
}
+3
View File
@@ -233,6 +233,9 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
target.Auth = s.generateRandomCredential(targetProtocol)
case model.MTProto:
target.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(targetInbound.Settings))
case model.TUIC:
target.ID = uuid.NewString()
target.Password = s.generateRandomCredential(targetProtocol)
default:
target.ID = s.generateRandomCredential(targetProtocol)
}
@@ -0,0 +1,66 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestBuildTargetClientFromSourceTuic(t *testing.T) {
s := &InboundService{}
source := model.Client{
Email: "test@example.com",
ID: "old-uuid",
Password: "old-password",
}
targetInbound := &model.Inbound{
Protocol: model.TUIC,
}
target, err := s.buildTargetClientFromSource(source, targetInbound, "test@example.com", "")
if err != nil {
t.Fatalf("buildTargetClientFromSource failed: %v", err)
}
if target.ID == "" || target.ID == "old-uuid" {
t.Fatalf("expected new UUID for TUIC client, got %q", target.ID)
}
if target.Password == "" || target.Password == "old-password" {
t.Fatalf("expected new password for TUIC client, got %q", target.Password)
}
}
func TestAddInboundTuicClientValidation(t *testing.T) {
setupConflictDB(t)
s := &InboundService{}
ib := &model.Inbound{
Tag: "tuic-test-1",
Protocol: model.TUIC,
Settings: `{"clients":[{"id":"uuid-1","password":""}]}`,
}
_, _, err := s.AddInbound(ib)
if err == nil || strings.TrimSpace(err.Error()) != "tuic client requires a password" {
t.Fatalf("expected 'tuic client requires a password' error, got %v", err)
}
ibNoID := &model.Inbound{
Tag: "tuic-test-2",
Protocol: model.TUIC,
Settings: `{"clients":[{"id":"","password":"pass"}]}`,
}
_, _, errNoID := s.AddInbound(ibNoID)
if errNoID == nil || strings.TrimSpace(errNoID.Error()) != "empty client ID" {
t.Fatalf("expected 'empty client ID' error, got %v", errNoID)
}
ibNoEmail := &model.Inbound{
Tag: "tuic-test-3",
Protocol: model.TUIC,
Settings: `{"clients":[{"id":"uuid-3","password":"pass","email":""}]}`,
}
_, _, errNoEmail := s.AddInbound(ibNoEmail)
if errNoEmail == nil || strings.TrimSpace(errNoEmail.Error()) != "empty client email" {
t.Fatalf("expected 'empty client email' error, got %v", errNoEmail)
}
}
@@ -83,6 +83,10 @@ func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool
s.applyLocalAmneziaWG(plan.inbound.Id)
continue
}
if plan.inbound.Protocol == model.TUIC {
s.applyLocalTuic(plan.inbound.Id)
continue
}
rt, err := s.runtimeFor(&plan.inbound)
if err == nil {
switch plan.action {
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"context"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.TUIC, true).
Find(&inbounds).Error
if err != nil {
return nil, err
}
if len(inbounds) == 0 {
return nil, nil
}
ids := make([]int, 0, len(inbounds))
for _, ib := range inbounds {
ids = append(ids, ib.Id)
}
var disabledRows []xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).
Where("inbound_id IN ? AND enable = ?", ids, false).
Select("inbound_id", "email").
Find(&disabledRows).Error
if err != nil {
return nil, err
}
disabled := make(map[int]map[string]struct{}, len(disabledRows))
for _, row := range disabledRows {
if disabled[row.InboundId] == nil {
disabled[row.InboundId] = map[string]struct{}{}
}
disabled[row.InboundId][row.Email] = struct{}{}
}
instances := make([]tuic.Instance, 0, len(inbounds))
for _, ib := range inbounds {
inst, ok := tuic.InstanceFromInbound(ib)
if !ok {
continue
}
if off := disabled[ib.Id]; len(off) > 0 {
kept := make([]tuic.TuicClientSettings, 0, len(inst.Clients))
for _, c := range inst.Clients {
if _, skip := off[c.Email]; !skip {
kept = append(kept, c)
}
}
inst.Clients = kept
}
if len(inst.Clients) == 0 {
continue
}
instances = append(instances, inst)
}
return instances, nil
}
func (s *InboundService) applyLocalTuic(inboundId int) {
inbound, err := s.GetInbound(inboundId)
if err != nil || inbound == nil || inbound.Protocol != model.TUIC || inbound.NodeID != nil {
return
}
rt, err := s.runtimeFor(inbound)
if err != nil {
return
}
payload := inbound
if inbound.Enable {
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
payload = built
}
}
if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
logger.Debug("tuic: immediate client apply failed for inbound", inboundId, ":", err)
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ const (
func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
// protocols that ignore streamSettings entirely.
switch protocol {
case model.Hysteria, model.WireGuard, model.AmneziaWG:
case model.Hysteria, model.WireGuard, model.AmneziaWG, model.TUIC:
return transportUDP
case model.MTProto:
return transportTCP
+1 -1
View File
@@ -180,7 +180,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if inbound.NodeID != nil {
continue
}
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG || inbound.Protocol == model.TUIC {
continue
}
settings := map[string]any{}
+19 -2
View File
@@ -675,7 +675,8 @@
"bytesPerSec": "بايت في الثانية",
"bytesPerSecTip": "حد السرعة (بايت/ثانية) المطبّق على مرور الـ fallback بعد العتبة، عشان الفحوصات ماتقدرش تستخدم سيرفرك كنطاق ترددي مجاني للهدف. 0 = بلا حد (بيعطّل الاتجاه ده).",
"burstBytesPerSec": "بايت في الثانية للدفقة",
"burstBytesPerSecTip": "السماح بدفقات قصيرة فوق المعدل الثابت (حجم token-bucket). لو أقل من بايت في الثانية بيترفع ليطابقه."
"burstBytesPerSecTip": "السماح بدفقات قصيرة فوق المعدل الثابت (حجم token-bucket). لو أقل من بايت في الثانية بيترفع ليطابقه.",
"autoFill": "الملء التلقائي"
},
"info": {
"mode": "الوضع",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "تكوين Peer {n}"
},
"sniffingDestOverride": "تجاوز الوجهة"
"sniffingDestOverride": "تجاوز الوجهة",
"advancedTitle": "الإعدادات المتقدمة"
},
"clients": {
"tabBasics": "أساسي",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "إعدادات AmneziaWG",
"tuicConfig": "إعدادات TUIC (Clash)",
"mtprotoSecret": "سر MTProto",
"mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
"mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
@@ -915,6 +918,7 @@
"renewOnDay": "يوم التجديد",
"renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
"renewsUsed": "التجديدات المستخدمة",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "التحكم في الازدحام",
"alpn": "ALPN",
"udpRelayMode": "وضع ترحيل UDP",
"zeroRttHandshake": "مصافحة 0-RTT",
"logLevel": "مستوى السجل",
"logLevelHint": "يتم استنتاج حالة الاتصال وآخر ظهور و«البدء بعد أول استخدام» من سطور Info الخاصة بالعملية الجانبية؛ اختيار Warn أو Error يعطّلها لهذا الإنباوند.",
"maxIdleTime": "أقصى وقت خمول (ثوانٍ)",
"authTimeout": "مهلة المصادقة (ثوانٍ)",
"maxUdpRelayPacketSize": "أقصى حجم لحزمة UDP",
"sni": "SNI",
"sniHint": "تجاوز إشارة اسم الخادم"
},
"tun": {
"userLevel": "مستوى المستخدم"
},
+18 -1
View File
@@ -410,6 +410,7 @@
"stream": "Stream",
"streamHelp": "Xray stream block wrapper:"
},
"advancedTitle": "Advanced Settings",
"subSortIndex": "Sub order",
"inboundInfo": "Inbound Information",
"exportInbound": "Export Inbound",
@@ -457,6 +458,7 @@
"moreIssues": "{message} (+{count} more)"
},
"form": {
"autoFill": "Auto Fill",
"moveUp": "Move up",
"moveDown": "Move down",
"addAll": "Add all",
@@ -768,7 +770,7 @@
"limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
"limitIpDisabled": "The IP limit feature is disabled on this server.",
"password": "Password",
"passwordDesc": "Only used by Trojan and Shadowsocks clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
"passwordDesc": "Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
"subId": "Subscription ID",
"online": "Online",
"email": "Email",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "AmneziaWG config",
"tuicConfig": "TUIC config (Clash)",
"mtprotoSecret": "MTProto secret",
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
"mtprotoAdTag": "Ad-tag (sponsored channel)",
@@ -915,6 +918,7 @@
"renewOnDay": "Renew on day",
"renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
"renewsUsed": "Renewals used",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds."
},
"groups": {
@@ -2078,6 +2082,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Congestion Control",
"alpn": "ALPN",
"udpRelayMode": "UDP Relay Mode",
"zeroRttHandshake": "Zero-RTT Handshake",
"logLevel": "Log Level",
"logLevelHint": "Online status, last online and \"start after first use\" are read from the sidecar's Info lines; Warn or Error turns them off for this inbound.",
"maxIdleTime": "Max Idle Time (s)",
"authTimeout": "Auth Timeout (s)",
"maxUdpRelayPacketSize": "Max UDP Packet Size",
"sni": "SNI",
"sniHint": "Server Name Indication override"
},
"tun": {
"userLevel": "User Level"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "Dirección del nodo",
"listen": "Dirección de escucha del inbound",
"custom": "Personalizada"
}
},
"autoFill": "Autocompletar"
},
"info": {
"mode": "Modo",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Config Peer {n}"
},
"sniffingDestOverride": "Anulación de destino"
"sniffingDestOverride": "Anulación de destino",
"advancedTitle": "Configuración avanzada"
},
"clients": {
"tabBasics": "Básico",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "Configuración de AmneziaWG",
"tuicConfig": "Configuración de TUIC (Clash)",
"mtprotoSecret": "Secreto MTProto",
"mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -915,6 +918,7 @@
"renewOnDay": "Renovar el día",
"renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
"renewsUsed": "Renovaciones usadas",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Control de congestión",
"alpn": "ALPN",
"udpRelayMode": "Modo de retransmisión UDP",
"zeroRttHandshake": "Apretón de manos 0-RTT",
"logLevel": "Nivel de registro",
"logLevelHint": "El estado en línea, la última conexión y «iniciar tras el primer uso» se leen de las líneas Info del sidecar; Warn o Error los desactivan para este inbound.",
"maxIdleTime": "Tiempo máx. de inactividad (s)",
"authTimeout": "Tiempo de espera de autenticación (s)",
"maxUdpRelayPacketSize": "Tamaño máx. de paquete UDP",
"sni": "SNI",
"sniHint": "Sobrescritura de Server Name Indication"
},
"tun": {
"userLevel": "Nivel de Usuario"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "آدرس نود",
"listen": "آدرس شنود ورودی",
"custom": "سفارشی"
}
},
"autoFill": "تکمیل خودکار"
},
"info": {
"mode": "حالت",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "پیکربندی Peer {n}"
},
"sniffingDestOverride": "بازنویسی مقصد"
"sniffingDestOverride": "بازنویسی مقصد",
"advancedTitle": "تنظیمات پیشرفته"
},
"clients": {
"tabBasics": "پایه",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "پیکربندی AmneziaWG",
"tuicConfig": "پیکربندی TUIC (Clash)",
"mtprotoSecret": "سکرت MTProto",
"mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
"mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
@@ -915,6 +918,7 @@
"renewOnDay": "روز تمدید",
"renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
"renewsUsed": "تمدیدهای استفاده‌شده",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "کنترل ازدحام",
"alpn": "ALPN",
"udpRelayMode": "حالت رله UDP",
"zeroRttHandshake": "دست دادن 0-RTT",
"logLevel": "سطح گزارش",
"logLevelHint": "وضعیت آنلاین، آخرین اتصال و «شروع پس از اولین استفاده» از خطوط Info سایدکار خوانده می‌شوند؛ Warn یا Error آن‌ها را برای این اینباند غیرفعال می‌کند.",
"maxIdleTime": "حداکثر زمان بیکاری (ثانیه)",
"authTimeout": "مهلت زمانی احراز هویت (ثانیه)",
"maxUdpRelayPacketSize": "حداکثر اندازه بسته UDP",
"sni": "SNI",
"sniHint": "بازنویسی نشانگر نام سرور"
},
"tun": {
"userLevel": "سطح کاربر"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"bytesPerSec": "Byte per detik",
"bytesPerSecTip": "Batas kecepatan (byte/detik) yang diterapkan pada lalu lintas fallback setelah ambang batas, agar probe tidak dapat memakai server Anda sebagai bandwidth gratis menuju target. 0 = tanpa batas (menonaktifkan arah ini).",
"burstBytesPerSec": "Byte per detik burst",
"burstBytesPerSecTip": "Kelonggaran untuk burst singkat di atas laju tetap (ukuran token-bucket). Jika lebih kecil dari Byte per detik, nilainya dinaikkan agar sama."
"burstBytesPerSecTip": "Kelonggaran untuk burst singkat di atas laju tetap (ukuran token-bucket). Jika lebih kecil dari Byte per detik, nilainya dinaikkan agar sama.",
"autoFill": "Isi Otomatis"
},
"info": {
"mode": "Mode",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Konfig Peer {n}"
},
"sniffingDestOverride": "Penggantian tujuan"
"sniffingDestOverride": "Penggantian tujuan",
"advancedTitle": "Pengaturan Lanjutan"
},
"clients": {
"tabBasics": "Dasar",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "Konfigurasi AmneziaWG",
"tuicConfig": "Konfigurasi TUIC (Clash)",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",
"mtprotoAdTag": "Ad-tag (kanal bersponsor)",
@@ -915,6 +918,7 @@
"renewOnDay": "Perpanjang pada tanggal",
"renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
"renewsUsed": "Perpanjangan terpakai",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Kontrol Kemacetan",
"alpn": "ALPN",
"udpRelayMode": "Mode Relai UDP",
"zeroRttHandshake": "Jabat Tangan 0-RTT",
"logLevel": "Tingkat Log",
"logLevelHint": "Status online, terakhir online, dan \"mulai setelah penggunaan pertama\" dibaca dari baris Info sidecar; Warn atau Error menonaktifkannya untuk inbound ini.",
"maxIdleTime": "Waktu Diam Maks (dtk)",
"authTimeout": "Batas Waktu Otentikasi (dtk)",
"maxUdpRelayPacketSize": "Ukuran Paket UDP Maks",
"sni": "SNI",
"sniHint": "Penggantian Server Name Indication"
},
"tun": {
"userLevel": "Level Pengguna"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "ノードアドレス",
"listen": "インバウンドのリッスンアドレス",
"custom": "カスタム"
}
},
"autoFill": "自動入力"
},
"info": {
"mode": "モード",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Peer {n} 設定"
},
"sniffingDestOverride": "宛先のオーバーライド"
"sniffingDestOverride": "宛先のオーバーライド",
"advancedTitle": "詳細設定"
},
"clients": {
"tabBasics": "基本",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "AmneziaWG 設定",
"tuicConfig": "TUIC 設定 (Clash)",
"mtprotoSecret": "MTProto シークレット",
"mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",
"mtprotoAdTag": "広告タグ(スポンサーチャンネル)",
@@ -915,6 +918,7 @@
"renewOnDay": "更新する日",
"renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
"renewsUsed": "使用済み更新回数",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。"
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "輻輳制御",
"alpn": "ALPN",
"udpRelayMode": "UDP リレーモード",
"zeroRttHandshake": "0-RTT ハンドシェイク",
"logLevel": "ログレベル",
"logLevelHint": "オンライン状態、最終オンライン、「初回使用後に開始」はサイドカーの Info 行から検出されます。Warn または Error を選ぶとこのインバウンドでは無効になります。",
"maxIdleTime": "最大アイドル時間 (秒)",
"authTimeout": "認証タイムアウト (秒)",
"maxUdpRelayPacketSize": "最大 UDP パケットサイズ",
"sni": "SNI",
"sniHint": "Server Name Indication の上書き"
},
"tun": {
"userLevel": "ユーザーレベル"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "Endereço do nó",
"listen": "Endereço de escuta do inbound",
"custom": "Personalizada"
}
},
"autoFill": "Preenchimento automático"
},
"info": {
"mode": "Modo",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Config Peer {n}"
},
"sniffingDestOverride": "Substituição de destino"
"sniffingDestOverride": "Substituição de destino",
"advancedTitle": "Configurações avançadas"
},
"clients": {
"tabBasics": "Básico",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "Configuração do AmneziaWG",
"tuicConfig": "Configuração do TUIC (Clash)",
"mtprotoSecret": "Segredo MTProto",
"mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -915,6 +918,7 @@
"renewOnDay": "Renovar no dia",
"renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
"renewsUsed": "Renovações usadas",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Controle de congestionamento",
"alpn": "ALPN",
"udpRelayMode": "Modo de retransmissão UDP",
"zeroRttHandshake": "Handshake 0-RTT",
"logLevel": "Nível de registro",
"logLevelHint": "Status online, último acesso e \"iniciar após o primeiro uso\" são lidos das linhas Info do sidecar; Warn ou Error os desativam para este inbound.",
"maxIdleTime": "Tempo máx. ocioso (s)",
"authTimeout": "Tempo limite de autenticação (s)",
"maxUdpRelayPacketSize": "Tamanho máx. do pacote UDP",
"sni": "SNI",
"sniHint": "Substituição do Server Name Indication"
},
"tun": {
"userLevel": "Nível do Usuário"
},
+18 -1
View File
@@ -410,6 +410,7 @@
"stream": "Stream",
"streamHelp": "Обёртка блока stream Xray:"
},
"advancedTitle": "Дополнительные настройки",
"subSortIndex": "Порядок",
"inboundInfo": "Информация о подключении",
"exportInbound": "Экспорт подключений",
@@ -457,6 +458,7 @@
"moreIssues": "{message} (+{count} ещё)"
},
"form": {
"autoFill": "Автозаполнение",
"echSockopt": "ECH Sockopt",
"echSockoptTip": "Параметры сокета для соединения, через которое Xray получает список конфигураций ECH (например, можно направить запрос через исходящее dialerProxy). Оставьте отключённым для значений по умолчанию.",
"curvePreferences": "Предпочтения кривых",
@@ -768,7 +770,7 @@
"limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
"limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
"password": "Пароль",
"passwordDesc": "Используется только клиентами Trojan и Shadowsocks; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
"passwordDesc": "Используется клиентами Trojan, Shadowsocks и TUIC; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
"subId": "ID подписки",
"online": "В сети",
"email": "Email",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (секунды)",
"tunnelKeepAliveHint": "Как часто клиент шлёт keepalive-пакет. Обычное значение 25: оно держит NAT-маппинг открытым и возвращает простаивающий пир сам после любого разрыва, включая рестарт панели. 0 отключает — тогда молчащий клиент остаётся отключённым до первого своего пакета.",
"amneziaWgConfig": "Конфиг AmneziaWG",
"tuicConfig": "Конфиг TUIC (Clash)",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
"mtprotoAdTag": "Рекламный тег (спонсорский канал)",
@@ -915,6 +918,7 @@
"renewOnDay": "Продлевать числа",
"renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
"renewsUsed": "Продлений израсходовано",
"tuicTotalGBDesc": "TUIC не поддерживает персональные квоты трафика; задайте лимит на уровне инбаунда.",
"bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Контроль перегрузок",
"alpn": "ALPN",
"udpRelayMode": "Режим ретрансляции UDP",
"zeroRttHandshake": "0-RTT рукопожатие",
"logLevel": "Уровень логирования",
"logLevelHint": "Статус онлайн, «последний раз онлайн» и «старт после первого использования» читаются из строк Info сайдкара; Warn или Error отключают их для этого инбаунда.",
"maxIdleTime": "Макс. время простоя (сек)",
"authTimeout": "Таймаут аутентификации (сек)",
"maxUdpRelayPacketSize": "Макс. размер пакета UDP",
"sni": "SNI",
"sniHint": "Переопределение Server Name Indication"
},
"tun": {
"userLevel": "Уровень пользователя"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"bytesPerSec": "Saniye Başına Bayt",
"bytesPerSecTip": "Eşik aşıldıktan sonra fallback trafiğine uygulanan hız sınırı (bayt/sn); böylece sondalar sunucunuzu hedefe ücretsiz bant genişliği olarak kullanamaz. 0 = sınır yok (bu yönü devre dışı bırakır).",
"burstBytesPerSec": "Saniye Başına Patlama Baytı",
"burstBytesPerSecTip": "Sabit hızın üzerindeki kısa patlamalar için pay (token-bucket boyutu). Saniye Başına Bayt değerinden düşükse ona eşitlenecek şekilde yükseltilir."
"burstBytesPerSecTip": "Sabit hızın üzerindeki kısa patlamalar için pay (token-bucket boyutu). Saniye Başına Bayt değerinden düşükse ona eşitlenecek şekilde yükseltilir.",
"autoFill": "Otomatik Doldur"
},
"info": {
"mode": "Mod",
@@ -694,7 +695,8 @@
"peerNumber": "Eş {n}",
"peerNumberConfig": "Peer {n} Yapılandırması"
},
"sniffingDestOverride": "Hedef geçersiz kılma"
"sniffingDestOverride": "Hedef geçersiz kılma",
"advancedTitle": "Gelişmiş Ayarlar"
},
"clients": {
"tabBasics": "Temel",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "AmneziaWG Yapılandırması",
"tuicConfig": "TUIC Yapılandırması (Clash)",
"mtprotoSecret": "MTProto sırrı",
"mtprotoSecretHint": "İstemcinin FakeTLS sırrı. Değiştirmek için yeniden oluşturun.",
"mtprotoAdTag": "Reklam etiketi (sponsorlu kanal)",
@@ -915,6 +918,7 @@
"renewOnDay": "Yenileme günü",
"renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
"renewsUsed": "Kullanılan yenileme",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Tıkanıklık Kontrolü",
"alpn": "ALPN",
"udpRelayMode": "UDP İletim Modu",
"zeroRttHandshake": "0-RTT El Sıkışması",
"logLevel": "Günlük Seviyesi",
"logLevelHint": "Çevrimiçi durumu, son görülme ve \"ilk kullanımdan sonra başlat\" sidecar'ın Info satırlarından okunur; Warn veya Error bunları bu inbound için kapatır.",
"maxIdleTime": "Maksimum Boşta Kalma Süresi (sn)",
"authTimeout": "Kimlik Doğrulama Zaman Aşımı (sn)",
"maxUdpRelayPacketSize": "Maksimum UDP Paket Boyutu",
"sni": "SNI",
"sniHint": "Sunucu Adı Belirtimi (SNI) geçersiz kılma"
},
"tun": {
"userLevel": "Kullanıcı Seviyesi"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "Адреса вузла",
"listen": "Адреса прослуховування inbound",
"custom": "Користувацька"
}
},
"autoFill": "Автозаповнення"
},
"info": {
"mode": "Режим",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Конфіг Peer {n}"
},
"sniffingDestOverride": "Перевизначення призначення"
"sniffingDestOverride": "Перевизначення призначення",
"advancedTitle": "Додаткові налаштування"
},
"clients": {
"tabBasics": "Основні",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "Конфігурація AmneziaWG",
"tuicConfig": "Конфігурація TUIC (Clash)",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
"mtprotoAdTag": "Рекламний тег (спонсорський канал)",
@@ -915,6 +918,7 @@
"renewOnDay": "Подовжувати числа",
"renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
"renewsUsed": "Подовжень витрачено",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Контроль перевантаження",
"alpn": "ALPN",
"udpRelayMode": "Режим ретрансляції UDP",
"zeroRttHandshake": "0-RTT рукостискання",
"logLevel": "Рівень журналювання",
"logLevelHint": "Статус онлайн, «востаннє онлайн» і «старт після першого використання» читаються з рядків Info сайдкара; Warn або Error вимикають їх для цього інбаунда.",
"maxIdleTime": "Макс. час простою (сек)",
"authTimeout": "Таймаут автентифікації (сек)",
"maxUdpRelayPacketSize": "Макс. розмір пакета UDP",
"sni": "SNI",
"sniHint": "Перевизначення Server Name Indication"
},
"tun": {
"userLevel": "Рівень користувача"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "Địa chỉ node",
"listen": "Địa chỉ listen inbound",
"custom": "Tùy chỉnh"
}
},
"autoFill": "Tự động điền"
},
"info": {
"mode": "Chế độ",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Cấu hình Peer {n}"
},
"sniffingDestOverride": "Ghi đè đích"
"sniffingDestOverride": "Ghi đè đích",
"advancedTitle": "Cài đặt nâng cao"
},
"clients": {
"tabBasics": "Cơ bản",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "Cấu hình AmneziaWG",
"tuicConfig": "Cấu hình TUIC (Clash)",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS của client. Tạo lại để thay đổi.",
"mtprotoAdTag": "Ad-tag (kênh tài trợ)",
@@ -915,6 +918,7 @@
"renewOnDay": "Gia hạn vào ngày",
"renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
"renewsUsed": "Số lần gia hạn đã dùng",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto."
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "Kiểm soát tắc nghẽn",
"alpn": "ALPN",
"udpRelayMode": "Chế độ chuyển tiếp UDP",
"zeroRttHandshake": "Bắt tay 0-RTT",
"logLevel": "Mức ghi nhật ký",
"logLevelHint": "Trạng thái trực tuyến, lần trực tuyến cuối và \"bắt đầu sau lần dùng đầu tiên\" được đọc từ các dòng Info của sidecar; Warn hoặc Error sẽ tắt chúng cho inbound này.",
"maxIdleTime": "Thời gian nhàn rỗi tối đa (giây)",
"authTimeout": "Thời gian chờ xác thực (giây)",
"maxUdpRelayPacketSize": "Kích thước gói UDP tối đa",
"sni": "SNI",
"sniHint": "Ghi đè Server Name Indication"
},
"tun": {
"userLevel": "Mức Người Dùng"
},
+20 -3
View File
@@ -675,7 +675,8 @@
"listen": "入站监听地址",
"custom": "自定义"
},
"verifyPeerCertByName": "按名称验证对端证书"
"verifyPeerCertByName": "按名称验证对端证书",
"autoFill": "自动填充"
},
"info": {
"mode": "模式",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Peer {n} 配置"
},
"sniffingDestOverride": "目标覆盖"
"sniffingDestOverride": "目标覆盖",
"advancedTitle": "高级设置"
},
"clients": {
"tabBasics": "基本",
@@ -768,7 +770,7 @@
"limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
"limitIpDisabled": "此服务器已禁用 IP 限制功能。",
"password": "密码",
"passwordDesc": " TrojanShadowsocks 客户端使用;VLESS、VMess、Hysteria 和 WireGuard 忽略此项。",
"passwordDesc": "用于 TrojanShadowsocks 和 TUIC 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。",
"subId": "订阅 ID",
"online": "在线",
"email": "邮箱",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "AmneziaWG 配置",
"tuicConfig": "TUIC 配置 (Clash)",
"mtprotoSecret": "MTProto 密钥",
"mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
"mtprotoAdTag": "广告标签(赞助频道)",
@@ -915,6 +918,7 @@
"renewOnDay": "按日期续期",
"renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
"renewsUsed": "已用续期次数",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。"
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "拥塞控制算法",
"alpn": "ALPN",
"udpRelayMode": "UDP 转发模式",
"zeroRttHandshake": "0-RTT 握手",
"logLevel": "日志级别",
"logLevelHint": "在线状态、最后在线时间和“首次使用后开始”来自 sidecar 的 Info 日志行;选择 Warn 或 Error 会使此入站失去这些功能。",
"maxIdleTime": "最大空闲时间 (秒)",
"authTimeout": "认证超时时间 (秒)",
"maxUdpRelayPacketSize": "最大 UDP 数据包大小",
"sni": "SNI",
"sniHint": "服务器名称指示 (SNI) 覆盖"
},
"tun": {
"userLevel": "用户级别"
},
+19 -2
View File
@@ -675,7 +675,8 @@
"node": "節點地址",
"listen": "入站監聽地址",
"custom": "自訂"
}
},
"autoFill": "自動填入"
},
"info": {
"mode": "模式",
@@ -694,7 +695,8 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Peer {n} 設定"
},
"sniffingDestOverride": "目標覆寫"
"sniffingDestOverride": "目標覆寫",
"advancedTitle": "進階設定"
},
"clients": {
"tabBasics": "基本",
@@ -882,6 +884,7 @@
"tunnelKeepAlive": "Keepalive (seconds)",
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
"amneziaWgConfig": "AmneziaWG 設定",
"tuicConfig": "TUIC 設定 (Clash)",
"mtprotoSecret": "MTProto 金鑰",
"mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
"mtprotoAdTag": "廣告標籤(贊助頻道)",
@@ -915,6 +918,7 @@
"renewOnDay": "按日期續期",
"renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
"renewsUsed": "已用續期次數",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。"
},
"groups": {
@@ -1960,6 +1964,19 @@
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
"outboundObfuscationHint": "Must exactly match the server side parameters."
},
"tuic": {
"congestionControl": "壅塞控制演算法",
"alpn": "ALPN",
"udpRelayMode": "UDP 轉發模式",
"zeroRttHandshake": "0-RTT 握手",
"logLevel": "日誌層級",
"logLevelHint": "線上狀態、最後上線時間和「首次使用後開始」來自 sidecar 的 Info 日誌行;選擇 Warn 或 Error 會使此入站失去這些功能。",
"maxIdleTime": "最大閒置時間 (秒)",
"authTimeout": "認證逾時時間 (秒)",
"maxUdpRelayPacketSize": "最大 UDP 封包大小",
"sni": "SNI",
"sniHint": "伺服器名稱指示 (SNI) 覆蓋"
},
"tun": {
"userLevel": "用戶級別"
},
+7
View File
@@ -21,6 +21,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
@@ -293,6 +294,7 @@ const (
cadenceXrayTraffic = "@every 5s"
cadenceMtproto = "@every 10s"
cadenceAmneziaWG = "@every 10s"
cadenceTuic = "@every 10s"
cadenceClientIPScan = "@every 10s"
cadenceNodeHeartbeat = "@every 5s"
cadenceNodeTraffic = "@every 5s"
@@ -339,6 +341,10 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
_, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
go awgJob.Run()
tuicJob := job.NewTuicJob()
_, _ = s.cron.AddJob(cadenceTuic, tuicJob)
go tuicJob.Run()
// check client ips from log file every 10 sec
_, _ = s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
@@ -699,6 +705,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
amneziawgnet.GetManager().StopAll()
tuic.GetManager().StopAll()
amneziawgnet.GetOutboundManager().StopAll()
}
if s.cron != nil {
+4
View File
@@ -117,6 +117,10 @@ func run(root, outDir string) error {
Path: resolveRel(root, "internal/amneziawg"),
StructAllow: setOf("ServerSettings"),
},
{
Path: resolveRel(root, "internal/tuic"),
StructAllow: setOf("TuicServerSettings", "TuicClientSettings"),
},
}
schemas, aliases, err := walkPackages(requests)
+4
View File
@@ -1086,6 +1086,7 @@ update_x-ui() {
# an inbound port with an outdated secret, silently breaking new clients.
# The new panel respawns a clean mtg per inbound on next start.
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
pkill -f 'tuic-server.*-c .*bin/tuic/tuic_[0-9]+\.json' > /dev/null 2>&1 || true
echo -e "${green}Removing old x-ui version...${plain}"
rm ${xui_folder} -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
@@ -1137,6 +1138,9 @@ update_x-ui() {
elif [[ -f bin/mtg-linux-$(arch) ]]; then
chmod +x bin/mtg-linux-$(arch) > /dev/null 2>&1
fi
if [[ -f bin/tuic-server ]]; then
chmod +x bin/tuic-server > /dev/null 2>&1
fi
echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
local xui_script_temp="/usr/bin/x-ui-temp.$$"