diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e44a8db3..1535b69bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 .. diff --git a/DockerInit.sh b/DockerInit.sh index 6c7d1795d..82a9abb83 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -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 diff --git a/docs/content/docs/en/config/inbounds.mdx b/docs/content/docs/en/config/inbounds.mdx index c02992478..b409f8180 100644 --- a/docs/content/docs/en/config/inbounds.mdx +++ b/docs/content/docs/en/config/inbounds.mdx @@ -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). | Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol diff --git a/docs/content/docs/en/config/meta.json b/docs/content/docs/en/config/meta.json index 293a286ce..e11e806f2 100644 --- a/docs/content/docs/en/config/meta.json +++ b/docs/content/docs/en/config/meta.json @@ -7,6 +7,7 @@ "inbounds", "reality", "amneziawg", + "tuic", "transports", "clients", "subscription", diff --git a/docs/content/docs/en/config/share-links.mdx b/docs/content/docs/en/config/share-links.mdx index 2d5aa52e7..8d596a4fb 100644 --- a/docs/content/docs/en/config/share-links.mdx +++ b/docs/content/docs/en/config/share-links.mdx @@ -17,6 +17,7 @@ as v2rayNG, Hiddify, and Mihomo import these links to configure themselves. | `ss://` | `ss://@:?#` (SIP002; Shadowsocks-2022 uses percent-encoded userinfo) | | `hysteria2://` | `hysteria2://@:?#` | | `tg://proxy` | `tg://proxy?server=…&port=…&secret=…` (MTProto) | +| `tuic://` | `tuic://:@:?#` (TUIC v5) | The query parameters carry the transport and security settings — `security`, `sni`, `fp`, `pbk`, `sid`, `spx`, `flow`, `type`, `path`, `host`, `alpn`, and diff --git a/docs/content/docs/en/config/tuic.mdx b/docs/content/docs/en/config/tuic.mdx new file mode 100644 index 000000000..5bbb5c1b0 --- /dev/null +++ b/docs/content/docs/en/config/tuic.mdx @@ -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. + + + 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. + + +## 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 + + + + +### Add an inbound + +Create a new inbound and choose protocol **TUIC**. Assign a UDP port (e.g. `8443` or `443`). + + + +### 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. + + + +### Configure QUIC options + +The panel fills recommended defaults (`bbr`, `h3`, `native` UDP relay). Adjust timeouts or enable **Zero-RTT Handshake** if desired. + + + +### Add clients + +Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. The panel automatically generates secure random credentials when creating clients. + + + +### 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. + + + + +## 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://:@:?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark +``` + +## Architecture & Notes + + + - **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). + diff --git a/docs/content/docs/ru/config/inbounds.mdx b/docs/content/docs/ru/config/inbounds.mdx index 92c5250a5..9760d0590 100644 --- a/docs/content/docs/ru/config/inbounds.mdx +++ b/docs/content/docs/ru/config/inbounds.mdx @@ -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). | Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria` diff --git a/docs/content/docs/ru/config/meta.json b/docs/content/docs/ru/config/meta.json index 45e015678..6084601c1 100644 --- a/docs/content/docs/ru/config/meta.json +++ b/docs/content/docs/ru/config/meta.json @@ -6,6 +6,7 @@ "ssl-certificates", "inbounds", "reality", + "tuic", "transports", "clients", "subscription", diff --git a/docs/content/docs/ru/config/share-links.mdx b/docs/content/docs/ru/config/share-links.mdx index f5daad1bd..74bcf760f 100644 --- a/docs/content/docs/ru/config/share-links.mdx +++ b/docs/content/docs/ru/config/share-links.mdx @@ -17,6 +17,7 @@ icon: Link | `ss://` | `ss://@:?#` (SIP002; Shadowsocks-2022 использует userinfo с процентным кодированием) | | `hysteria2://` | `hysteria2://@:?#` | | `tg://proxy` | `tg://proxy?server=…&port=…&secret=…` (MTProto) | +| `tuic://` | `tuic://:@:?#` (TUIC v5) | Параметры запроса несут настройки транспорта и безопасности — `security`, `sni`, `fp`, `pbk`, `sid`, `spx`, `flow`, `type`, `path`, `host`, `alpn` и diff --git a/docs/content/docs/ru/config/tuic.mdx b/docs/content/docs/ru/config/tuic.mdx new file mode 100644 index 000000000..e47b5222e --- /dev/null +++ b/docs/content/docs/ru/config/tuic.mdx @@ -0,0 +1,112 @@ +--- +title: TUIC +description: Настройка входящего подключения TUIC в 3x-ui — параметры перегрузок QUIC, 0-RTT рукопожатия и многопользовательская аутентификация. +icon: Zap +--- + +**TUIC** (v5) — это протокол проксирования, работающий поверх транспортного уровня **QUIC** (HTTP/3). +Он использует 0-RTT рукопожатия, мультиплексирование соединений без блокировки начала очереди +и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов. + + + Как и MTProto, TUIC работает как **изолированный процесс-сайдкар** (`tuic-server` 1.0.0, + написан на Rust), а не внутри Xray-core. Панель управляет жизненным циклом бинарника, + генерирует конфигурации, отслеживает его состояние, фиксирует общий трафик инбаунда + и онлайн-активность клиентов. + + +## Ключевые параметры + +### Параметры сервера и 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`). | + +## Настройка в панели + + + + +### Добавьте инбаунд + +Создайте новый инбаунд и выберите протокол **TUIC**. Задайте UDP-порт (например, `8443` или `443`). + + + +### Укажите TLS-сертификат + +Укажите пути к файлам сертификата и приватного ключа (или вставьте их содержимое напрямую). Убедитесь, что поле SNI совпадает с доменом сертификата. + + + +### Настройте параметры QUIC + +Панель автоматически подставляет рекомендованные настройки (`bbr`, `h3`, `native`). При необходимости настройте таймауты или включите **Zero-RTT Handshake**. + + + +### Добавьте клиентов + +Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Панель автоматически генерирует надёжные случайные данные при создании клиента. + + + +### Экспортируйте и подключитесь + +Скопируйте ссылку `tuic://…` или откройте **окно QR-кода**, чтобы скачать готовый конфигурационный файл **Clash / Mihomo YAML**. + + + + +## Поддержка клиентами и конфигурация + +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://:@:?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark +``` + +## Архитектура и примечания + + + - **Автономный сайдкар**: Панель поставляется со скомпилированными статическими `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 управляется локальным процессом хоста, такие инбаунды работают локально на главной панели. + diff --git a/docs/public/openapi.json b/docs/public/openapi.json index d85a472f7..0df587bf6 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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": "" diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d85a472f7..0df587bf6 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": "" diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index a36b4b6a2..9814a2270 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -731,6 +731,7 @@ export const EXAMPLES: Record = { "ssMethod": "", "tag": "in-443-tcp", "tlsFlowCapable": true, + "tuicServer": null, "wgDns": "", "wgMtu": 0, "wgPublicKey": "" @@ -1002,6 +1003,26 @@ export const EXAMPLES: Record = { "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": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index d55fe0b27..686713907 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2578,7 +2578,8 @@ export const SCHEMAS: Record = { "tunnel", "tun", "mtproto", - "amneziawg" + "amneziawg", + "tuic" ], "example": "vless", "type": "string" @@ -2797,6 +2798,14 @@ export const SCHEMAS: Record = { "example": true, "type": "boolean" }, + "tuicServer": { + "allOf": [ + { + "$ref": "#/components/schemas/TuicServerSettings" + } + ], + "nullable": true + }, "wgDns": { "type": "string" }, @@ -4050,6 +4059,78 @@ export const SCHEMAS: Record = { ], "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": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index df6c48ccc..d0a60f467 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 4b78792f4..2e8811949 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -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; +export const TuicClientSettingsSchema = z.object({ + email: z.string(), + password: z.string(), + uuid: z.string(), +}); +export type TuicClientSettings = z.infer; + +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; + export const UserSchema = z.object({ id: z.number().int(), password: z.string(), diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index 8b8f520c5..98b9f3481 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/frontend/src/lib/hosts/host-link.ts @@ -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, }; } diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 21bef0966..1dcb7708b 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -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; } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 781276b77..aaefb0742 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -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; } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 555d4846e..431b04419 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -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; + const server = (rawSettings.server as Record) ?? 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 ''; } diff --git a/frontend/src/lib/xray/inbound-tag.ts b/frontend/src/lib/xray/inbound-tag.ts index 8c0006075..a51628474 100644 --- a/frontend/src/lib/xray/inbound-tag.ts +++ b/frontend/src/lib/xray/inbound-tag.ts @@ -14,7 +14,13 @@ function inboundTransports( streamSettings: Record | undefined, settings: Record | 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); diff --git a/frontend/src/lib/xray/link-label.tsx b/frontend/src/lib/xray/link-label.tsx index 7690f6d30..aeccffb47 100644 --- a/frontend/src/lib/xray/link-label.tsx +++ b/frontend/src/lib/xray/link-label.tsx @@ -27,6 +27,7 @@ const PROTOCOL_LABELS: Record = { wg: 'WireGuard', tg: 'MTProto', vpn: 'AmneziaWG', + tuic: 'TUIC', }; const PROTOCOL_COLORS: Record = { @@ -39,6 +40,7 @@ const PROTOCOL_COLORS: Record = { WireGuard: 'cyan', MTProto: 'blue', AmneziaWG: 'yellow', + TUIC: 'orange', }; const SECURITY_COLORS: Record = { @@ -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 { diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index cff17639a..cc9c61e33 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -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 diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index fd18970b9..34a986f41 100644 --- a/frontend/src/models/dbinbound.ts +++ b/frontend/src/models/dbinbound.ts @@ -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; } diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx index cbe3cad3f..cbb0d1924 100644 --- a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx @@ -16,6 +16,7 @@ const MULTI_USER_PROTOCOLS = new Set([ 'wireguard', 'mtproto', 'amneziawg', + 'tuic', ]); interface BulkAttachInboundsModalProps { diff --git a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx index 8bd3d1571..6a0fd3e5e 100644 --- a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx @@ -16,6 +16,7 @@ const MULTI_USER_PROTOCOLS = new Set([ 'wireguard', 'mtproto', 'amneziawg', + 'tuic', ]); interface BulkDetachInboundsModalProps { diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 95ab17c7c..f1c50a81a 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -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(); + 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({ Number(v) || 0 }} > diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 991127a0d..e24c2c5a7 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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(); + 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({ Number(v) || 0 }} > diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index 15225cd9e..a662f0241 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -44,6 +44,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { http: 'purple', mixed: 'lime', tunnel: 'orange', + tuic: 'orange', }; const INBOUND_CHIP_LIMIT = 1; diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index 13b13f3b4..4995e621e 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -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: , }); }); + if (tuicConfigText) { + out.push({ + key: 'tuic-config', + label: ( + + {t('pages.clients.tuicConfig')} + + ), + children: ( + + ), + }); + } 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; diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index e39b8a85c..6b862c6ca 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -177,6 +177,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { 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 diff --git a/frontend/src/pages/clients/tuicConfig.ts b/frontend/src/pages/clients/tuicConfig.ts new file mode 100644 index 000000000..4d51a9738 --- /dev/null +++ b/frontend/src/pages/clients/tuicConfig.ts @@ -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, +): 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'); +} diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index f2a6627aa..5e6a5251b 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -43,6 +43,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { http: 'purple', mixed: 'lime', tunnel: 'orange', + tuic: 'orange', }; export function sortHosts(hosts: HostRecord[]): HostRecord[] { diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 33777ad5b..141e431bd 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -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 && } + {protocol === Protocols.TUN && } {protocol === Protocols.TUNNEL && } @@ -1135,6 +1139,7 @@ export default function InboundFormModal({ Protocols.WIREGUARD, Protocols.MTPROTO, Protocols.AMNEZIAWG, + Protocols.TUIC, ] as string[] ).includes(protocol) || isFallbackHost ? [ diff --git a/frontend/src/pages/inbounds/form/protocols/index.ts b/frontend/src/pages/inbounds/form/protocols/index.ts index 3d9341c96..cdbd41bef 100644 --- a/frontend/src/pages/inbounds/form/protocols/index.ts +++ b/frontend/src/pages/inbounds/form/protocols/index.ts @@ -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'; diff --git a/frontend/src/pages/inbounds/form/protocols/tuic.tsx b/frontend/src/pages/inbounds/form/protocols/tuic.tsx new file mode 100644 index 000000000..6f1503679 --- /dev/null +++ b/frontend/src/pages/inbounds/form/protocols/tuic.tsx @@ -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: ( + <> + + + + + + handleSniChange(e.target.value)} + style={{ flex: 1 }} + /> + + + + + + setValue('settings.server.certificate', v)} + placeholder="/root/cert.pem" + /> + + + + setValue('settings.server.private_key', v)} + placeholder="/root/privkey.pem" + /> + + + + + + + + + + + + + + +