From 6d96accd6338e9ea3c7e5665415d489f2c116355 Mon Sep 17 00:00:00 2001 From: amae <60270520+momachka2@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:15:48 +0500 Subject: [PATCH] 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//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//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//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 Co-authored-by: Sanaei --- .github/workflows/release.yml | 25 ++ DockerInit.sh | 22 ++ docs/content/docs/en/config/inbounds.mdx | 1 + docs/content/docs/en/config/meta.json | 1 + docs/content/docs/en/config/share-links.mdx | 1 + docs/content/docs/en/config/tuic.mdx | 113 +++++++ docs/content/docs/ru/config/inbounds.mdx | 1 + docs/content/docs/ru/config/meta.json | 1 + docs/content/docs/ru/config/share-links.mdx | 1 + docs/content/docs/ru/config/tuic.mdx | 112 +++++++ docs/public/openapi.json | 84 ++++- frontend/public/openapi.json | 84 ++++- frontend/src/generated/examples.ts | 21 ++ frontend/src/generated/schemas.ts | 83 ++++- frontend/src/generated/types.ts | 21 ++ frontend/src/generated/zod.ts | 25 +- frontend/src/lib/hosts/host-link.ts | 2 + frontend/src/lib/xray/inbound-defaults.ts | 41 ++- frontend/src/lib/xray/inbound-form-adapter.ts | 3 + frontend/src/lib/xray/inbound-link.ts | 77 +++++ frontend/src/lib/xray/inbound-tag.ts | 8 +- frontend/src/lib/xray/link-label.tsx | 6 + .../src/lib/xray/protocol-capabilities.ts | 4 +- frontend/src/models/dbinbound.ts | 4 + .../pages/clients/BulkAttachInboundsModal.tsx | 1 + .../pages/clients/BulkDetachInboundsModal.tsx | 1 + .../src/pages/clients/ClientBulkAddModal.tsx | 17 + .../src/pages/clients/ClientFormModal.tsx | 21 +- .../src/pages/clients/ClientInfoModal.tsx | 1 + frontend/src/pages/clients/ClientQrModal.tsx | 38 ++- frontend/src/pages/clients/ClientsPage.tsx | 1 + frontend/src/pages/clients/tuicConfig.ts | 64 ++++ frontend/src/pages/hosts/HostList.tsx | 1 + .../pages/inbounds/form/InboundFormModal.tsx | 7 +- .../pages/inbounds/form/protocols/index.ts | 1 + .../pages/inbounds/form/protocols/tuic.tsx | 252 +++++++++++++++ frontend/src/pages/inbounds/info/helpers.ts | 1 + frontend/src/pages/inbounds/list/helpers.ts | 1 + frontend/src/pages/inbounds/list/types.ts | 1 + .../pages/inbounds/list/useInboundColumns.tsx | 2 +- frontend/src/pages/inbounds/useInbounds.ts | 1 + frontend/src/schemas/client.ts | 18 ++ frontend/src/schemas/primitives/protocol.ts | 2 + .../src/schemas/protocols/inbound/index.ts | 3 + .../src/schemas/protocols/inbound/tuic.ts | 60 ++++ .../protocols/stream/external-proxy.ts | 1 + frontend/src/test/client-form-modal.test.tsx | 2 +- frontend/src/test/host-link.test.ts | 8 + frontend/src/test/inbound-defaults.test.ts | 19 ++ frontend/src/test/inbound-link.test.ts | 152 +++++++++ frontend/src/test/inbound-tag.test.ts | 5 + frontend/src/test/link-label.test.ts | 10 + frontend/src/test/tuic-client-config.test.ts | 63 ++++ install.sh | 36 ++- internal/database/model/model.go | 3 +- internal/sub/clash_service.go | 58 ++++ internal/sub/json_service.go | 2 +- internal/sub/json_service_test.go | 11 + internal/sub/service.go | 80 ++++- internal/sub/service_tuic_test.go | 190 ++++++++++++ internal/tuic/config.go | 94 ++++++ internal/tuic/config_test.go | 87 ++++++ internal/tuic/manager.go | 231 ++++++++++++++ internal/tuic/manager_test.go | 95 ++++++ internal/tuic/orphans_linux.go | 87 ++++++ internal/tuic/orphans_other.go | 5 + internal/tuic/process.go | 292 ++++++++++++++++++ internal/tuic/process_other.go | 7 + internal/tuic/process_windows.go | 67 ++++ internal/tuic/relay.go | 196 ++++++++++++ internal/tuic/relay_test.go | 127 ++++++++ internal/tuic/types.go | 249 +++++++++++++++ internal/tuic/types_test.go | 124 ++++++++ internal/web/job/tuic_job.go | 84 +++++ internal/web/runtime/local.go | 42 ++- internal/web/service/client_crud.go | 7 + internal/web/service/client_inbound_apply.go | 16 + internal/web/service/inbound.go | 56 +++- internal/web/service/inbound_clients.go | 3 + .../web/service/inbound_clients_tuic_test.go | 66 ++++ internal/web/service/inbound_traffic_apply.go | 4 + internal/web/service/inbound_tuic.go | 87 ++++++ internal/web/service/port_conflict.go | 2 +- internal/web/service/xray.go | 2 +- internal/web/translation/ar-EG.json | 21 +- internal/web/translation/en-US.json | 19 +- internal/web/translation/es-ES.json | 21 +- internal/web/translation/fa-IR.json | 21 +- internal/web/translation/id-ID.json | 21 +- internal/web/translation/ja-JP.json | 21 +- internal/web/translation/pt-BR.json | 21 +- internal/web/translation/ru-RU.json | 19 +- internal/web/translation/tr-TR.json | 21 +- internal/web/translation/uk-UA.json | 21 +- internal/web/translation/vi-VN.json | 21 +- internal/web/translation/zh-CN.json | 23 +- internal/web/translation/zh-TW.json | 21 +- internal/web/web.go | 7 + tools/openapigen/main.go | 4 + update.sh | 4 + 100 files changed, 4139 insertions(+), 52 deletions(-) create mode 100644 docs/content/docs/en/config/tuic.mdx create mode 100644 docs/content/docs/ru/config/tuic.mdx create mode 100644 frontend/src/pages/clients/tuicConfig.ts create mode 100644 frontend/src/pages/inbounds/form/protocols/tuic.tsx create mode 100644 frontend/src/schemas/protocols/inbound/tuic.ts create mode 100644 frontend/src/test/tuic-client-config.test.ts create mode 100644 internal/sub/service_tuic_test.go create mode 100644 internal/tuic/config.go create mode 100644 internal/tuic/config_test.go create mode 100644 internal/tuic/manager.go create mode 100644 internal/tuic/manager_test.go create mode 100644 internal/tuic/orphans_linux.go create mode 100644 internal/tuic/orphans_other.go create mode 100644 internal/tuic/process.go create mode 100644 internal/tuic/process_other.go create mode 100644 internal/tuic/process_windows.go create mode 100644 internal/tuic/relay.go create mode 100644 internal/tuic/relay_test.go create mode 100644 internal/tuic/types.go create mode 100644 internal/tuic/types_test.go create mode 100644 internal/web/job/tuic_job.go create mode 100644 internal/web/service/inbound_clients_tuic_test.go create mode 100644 internal/web/service/inbound_tuic.go 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" + /> + + + + + + + + + + + + + + +