14 Commits 05a083eaef ... 0054e671f8

Tác giả SHA1 Thông báo Ngày
  Egor 0054e671f8 feat(tuic): implement native in-process Go TUIC v5 server (#6577) 21 giờ trước cách đây
  MHSanaei 40ca2cd72f Reformat clientSearchCols slice literal 22 giờ trước cách đây
  MHSanaei bb18734c77 fix(tgbot): resolve the panel egress bridge per connection 22 giờ trước cách đây
  kaveh 4aa9a382b8 Keep a firewalld pulled in by fail2ban from blocking ports on EL7 (#6688) 22 giờ trước cách đây
  kaveh 3948b83405 Write config.json after a hot apply (#6686) 22 giờ trước cách đây
  kaveh 7802167443 Fix clients group filter and search for non-ASCII capitals (#6685) 22 giờ trước cách đây
  kaveh 5366eb0d29 Bound the panel syslog view with a journalctl timeout (#6689) 22 giờ trước cách đây
  Chester Fishmans 98db0710c9 fix(runtime): reset node inbound traffic by node-side id (#6717) 23 giờ trước cách đây
  Farhan Zare 3168c87c67 fix(sub): keep serverNames out of a reality host's JSON client config (#6691) 23 giờ trước cách đây
  Farhan Zare 93847dd106 fix(sub): keep the spider settings in a reality spiderX seed's query (#6694) 23 giờ trước cách đây
  MHSanaei ed31ee432c feat(inbounds): deploy AmneziaWG, TUIC and MTProto inbounds to nodes 23 giờ trước cách đây
  MHSanaei 9b957b969b fix(docker): build the frontend stage on Node 26 1 ngày trước cách đây
  MHSanaei 805f94a00c chore(amneziawgnet): bind test sockets to loopback 1 ngày trước cách đây
  MHSanaei 97bee832f4 refactor(util): move panel version comparison into a shared package 1 ngày trước cách đây
100 tập tin đã thay đổi với 7147 bổ sung và 1464 xóa
  1. 0 25
      .github/workflows/release.yml
  2. 0 21
      DockerInit.sh
  3. 1 1
      Dockerfile
  4. 6 7
      docs/architecture.md
  5. 9 5
      docs/content/docs/en/config/amneziawg.mdx
  6. 1 1
      docs/content/docs/en/config/clients.mdx
  7. 1 1
      docs/content/docs/en/config/inbounds.mdx
  8. 8 11
      docs/content/docs/en/config/tuic.mdx
  9. 1 1
      docs/content/docs/fa/config/clients.mdx
  10. 1 1
      docs/content/docs/fa/config/inbounds.mdx
  11. 1 1
      docs/content/docs/ru/config/clients.mdx
  12. 1 1
      docs/content/docs/ru/config/inbounds.mdx
  13. 8 11
      docs/content/docs/ru/config/tuic.mdx
  14. 1 1
      docs/content/docs/zh/config/clients.mdx
  15. 1 1
      docs/content/docs/zh/config/inbounds.mdx
  16. 35 0
      frontend/src/lib/tuic.ts
  17. 7 1
      frontend/src/lib/xray/inbound-form-adapter.ts
  18. 5 3
      frontend/src/lib/xray/inbound-link.ts
  19. 5 1
      frontend/src/lib/xray/node-protocols.ts
  20. 6 4
      frontend/src/lib/xray/spider-x.ts
  21. 1 16
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  22. 1 18
      frontend/src/pages/clients/ClientFormModal.tsx
  23. 2 1
      frontend/src/pages/clients/tuicConfig.ts
  24. 19 29
      frontend/src/pages/inbounds/form/protocols/tuic.tsx
  25. 25 5
      frontend/src/schemas/protocols/inbound/tuic.ts
  26. 42 0
      frontend/src/test/inbound-defaults.test.ts
  27. 35 0
      frontend/src/test/inbound-link.test.ts
  28. 6 0
      frontend/src/test/spider-x.test.ts
  29. 48 0
      frontend/src/test/tuic-client-config.test.ts
  30. 80 0
      frontend/src/test/tuic-legacy-form.test.tsx
  31. 2 2
      go.mod
  32. 4 32
      install.sh
  33. 1 2
      internal/amneziawgnet/bench_test.go
  34. 3 4
      internal/amneziawgnet/device_test.go
  35. 1 2
      internal/amneziawgnet/diagnostics_test.go
  36. 11 0
      internal/amneziawgnet/main_test.go
  37. 13 2
      internal/amneziawgnet/pinned_bind.go
  38. 3 0
      internal/amneziawgnet/pinned_bind_test.go
  39. 2 2
      internal/amneziawgnet/portfwd.go
  40. 2 3
      internal/amneziawgnet/portfwd_test.go
  41. 2 2
      internal/amneziawgnet/portfwd_udp.go
  42. 2 3
      internal/amneziawgnet/relay_e2e_test.go
  43. 1 2
      internal/amneziawgnet/udp_test.go
  44. 1 0
      internal/database/db.go
  45. 1 0
      internal/database/migrate_data.go
  46. 7 0
      internal/database/model/tuic_traffic_receipt.go
  47. 2 3
      internal/sub/host_sub.go
  48. 55 0
      internal/sub/host_sub_test.go
  49. 7 3
      internal/sub/service.go
  50. 21 0
      internal/sub/service_sharelink_test.go
  51. 7 1
      internal/sub/service_tuic_test.go
  52. 220 0
      internal/tuic/auth.go
  53. 180 0
      internal/tuic/auth_test.go
  54. 0 94
      internal/tuic/config.go
  55. 0 87
      internal/tuic/config_test.go
  56. 112 81
      internal/tuic/manager.go
  57. 248 0
      internal/tuic/manager_live_traffic_test.go
  58. 140 0
      internal/tuic/manager_shutdown_test.go
  59. 352 48
      internal/tuic/manager_test.go
  60. 0 87
      internal/tuic/orphans_linux.go
  61. 0 5
      internal/tuic/orphans_other.go
  62. 0 292
      internal/tuic/process.go
  63. 0 7
      internal/tuic/process_other.go
  64. 0 67
      internal/tuic/process_windows.go
  65. 225 0
      internal/tuic/protocol.go
  66. 145 0
      internal/tuic/protocol_test.go
  67. 0 211
      internal/tuic/relay.go
  68. 263 0
      internal/tuic/relay_logging_test.go
  69. 243 0
      internal/tuic/relay_recovery_test.go
  70. 88 0
      internal/tuic/relay_shutdown_test.go
  71. 0 150
      internal/tuic/relay_test.go
  72. 47 0
      internal/tuic/revocation_snapshot_test.go
  73. 1069 0
      internal/tuic/server.go
  74. 129 0
      internal/tuic/server_settings_test.go
  75. 758 0
      internal/tuic/server_test.go
  76. 578 0
      internal/tuic/socks_bridge.go
  77. 416 0
      internal/tuic/socks_bridge_test.go
  78. 200 0
      internal/tuic/stream_udp_lifecycle_test.go
  79. 47 23
      internal/tuic/types.go
  80. 37 1
      internal/tuic/types_test.go
  81. 186 0
      internal/tuic/udp_associations.go
  82. 47 0
      internal/util/version/version.go
  83. 12 0
      internal/util/version/version_test.go
  84. 10 4
      internal/web/job/periodic_traffic_reset_nodes_test.go
  85. 57 30
      internal/web/job/tuic_job.go
  86. 182 0
      internal/web/job/tuic_job_test.go
  87. 135 0
      internal/web/job/tuic_journal.go
  88. 67 0
      internal/web/job/tuic_journal_test.go
  89. 1 1
      internal/web/network/serve_test.go
  90. 25 1
      internal/web/runtime/local.go
  91. 38 0
      internal/web/runtime/local_tuic_test.go
  92. 5 1
      internal/web/runtime/remote.go
  93. 49 0
      internal/web/runtime/remote_reset_test.go
  94. 4 4
      internal/web/service/client_inbound_apply.go
  95. 87 1
      internal/web/service/client_link.go
  96. 32 0
      internal/web/service/client_link_postgres_test.go
  97. 62 11
      internal/web/service/client_paging.go
  98. 33 0
      internal/web/service/client_paging_test.go
  99. 82 14
      internal/web/service/inbound.go
  100. 31 14
      internal/web/service/inbound_amneziawg.go

+ 0 - 25
.github/workflows/release.yml

@@ -171,28 +171,6 @@ 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
@@ -325,9 +303,6 @@ 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 ..

+ 0 - 21
DockerInit.sh

@@ -50,27 +50,6 @@ tar -xzf "${MTG_PKG}.tar.gz"
 mv "${MTG_PKG}/mtg-multi" "mtg-linux-${FNAME}"
 rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
 chmod +x "mtg-linux-${FNAME}"
-case $FNAME in
-    amd64)
-        curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-unknown-linux-musl"
-        ;;
-    arm64)
-        curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-aarch64-unknown-linux-musl"
-        ;;
-    arm32)
-        curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-armv7-unknown-linux-musleabihf"
-        ;;
-    i386)
-        curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-i686-unknown-linux-musl"
-        ;;
-esac
-if [ -f "tuic-server" ]; then
-    if [ ! -s "tuic-server" ]; then
-        echo "DockerInit: tuic-server download was empty" >&2
-        exit 1
-    fi
-    chmod +x "tuic-server"
-fi
 curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
 curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
 curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat

+ 1 - 1
Dockerfile

@@ -1,7 +1,7 @@
 # ========================================================
 # Stage: Frontend (Vite)
 # ========================================================
-FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
+FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend
 WORKDIR /src/frontend
 COPY frontend/package.json frontend/package-lock.json ./
 RUN npm ci

+ 6 - 7
docs/architecture.md

@@ -19,8 +19,8 @@ Xray JSON config from that state, supervises the Xray child process, and exposes
 WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the UI. A second,
 separate HTTP server serves **subscription links** to end users.
 
-The panel supervises **managed child processes**: Xray-core itself and — when MTProto or
-TUIC inbounds exist — dedicated child proxy binaries:
+The panel supervises **managed child processes**: Xray-core itself and — when MTProto
+inbounds exist — a dedicated child proxy binary:
 
 - **`mtg-multi` for MTProto inbounds** (`github.com/mhsanaei/mtg-multi`, a multi-secret fork
   built from source; `internal/mtproto/`): One process per inbound serves every attached
@@ -28,10 +28,10 @@ TUIC inbounds exist — dedicated child proxy binaries:
   sponsored-channel ad-tags via `[secret-ad-tags]`. A client or ad-tag edit is hot-applied via
   the fork's management API (`PUT /secrets`, guarded by a per-process bearer token), with a
   process restart as the fallback on older binaries.
-- **`tuic-server` for TUIC v5 inbounds** (`internal/tuic/`): One process per inbound runs on
-  loopback behind an in-process native Go UDP relay that owns the public port and meters
-  traffic deltas. The sidecar handles decrypted client traffic standalone, independent of
-  Xray routing and outbounds.
+
+In contrast, **AmneziaWG** (`internal/amneziawgnet/`) and **TUIC v5** (`internal/tuic/`) run as
+**in-process native Go servers** without external child processes, bridging client traffic into
+Xray-core via loopback SOCKS5 relays.
 
 Servers and processes, all launched from `main.go`:
 
@@ -41,7 +41,6 @@ Servers and processes, all launched from `main.go`:
 | **Subscription** | `internal/sub`                    | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
 | **Xray-core**    | supervised via `internal/xray`    | The actual proxy engine; a child process, not Go code              | `inbounds[].port` |
 | **mtg-multi**    | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret)    | per inbound       |
-| **tuic-server**  | supervised via `internal/tuic`    | TUIC v5 proxy child process fronted by a Go UDP relay              | per inbound       |
 
 Two key ideas that explain most of the complexity:
 

+ 9 - 5
docs/content/docs/en/config/amneziawg.mdx

@@ -164,14 +164,18 @@ Endpoint = your-server:443
 PersistentKeepalive = 25
 ```
 
+## Multi-node (sub-nodes)
+
+An AmneziaWG inbound can be created on, or cloned to, a sub-node. The node's
+own panel runs the interface, so the node must run panel **v3.7.0** or newer;
+the master refuses an older node, or one that has not reported its version yet.
+A client's `forwardedPorts` are checked against the ports in use on that node.
+
 ## Not yet covered
 
 <Callout type="info">
 
-- **Multi-node (sub-nodes)** and **Telegram bot** — AmneziaWG inbounds haven't
-  been exercised through those paths yet. They likely work (the reconciler
-  runs the same way regardless of how the panel itself is deployed), but
-  that's not the same as a confirmed, tested claim — treat it as unverified
-  rather than assume it either way until someone reports back.
+- **Telegram bot** — AmneziaWG inbounds haven't been exercised through the
+  bot yet. Treat it as unverified until someone reports back.
 
 </Callout>

+ 1 - 1
docs/content/docs/en/config/clients.mdx

@@ -18,7 +18,7 @@ inbounds** at once, with per-client traffic accounting.
 | **Auth**       | Hysteria2             | The client credential.                                             |
 | **Flow**       | VLESS                 | XTLS flow, e.g. `xtls-rprx-vision`.                                |
 | **Limit IP**   | all (except TUIC)     | Max simultaneous source IPs (enforced via Fail2ban).               |
-| **Total (GB)** | all (except TUIC)     | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
+| **Total (GB)** | all                   | Traffic quota; the client is disabled when exhausted.              |
 | **Expiry**     | all                   | Date after which the client stops working.                        |
 | **Auto renewal** | all                 | Disabled, fixed interval in days, calendar weekly, or calendar monthly. |
 | **Telegram ID**| all                   | Links the client to a Telegram user for self-service/notifications.|

+ 1 - 1
docs/content/docs/en/config/inbounds.mdx

@@ -64,7 +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). |
+| **TUIC**               | QUIC-based proxy protocol (v5), served by an in-process native Go server. See [TUIC](/docs/config/tuic). |
 
 <Callout type="info">
   Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol

+ 8 - 11
docs/content/docs/en/config/tuic.mdx

@@ -10,10 +10,7 @@ and custom congestion control algorithms to maintain stable connections over los
 unstable networks.
 
 <Callout type="info">
-  Like MTProto, TUIC runs as a **managed sidecar process** (`tuic-server` 1.0.0,
-  written in Rust) rather than inside Xray-core. The panel manages the binary
-  lifecycle, generates configurations, monitors process health, and tracks
-  inbound traffic and client online presence.
+  TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and zero-downtime hot user updates without restarting the port.
 </Callout>
 
 ## Key settings
@@ -25,7 +22,7 @@ unstable networks.
 | **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`. |
+| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. The server runs `bbr` or `new_reno`; `cubic` is sent to clients but served as `new_reno`. |
 | **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. |
@@ -102,12 +99,12 @@ TUIC share links use standard URI formatting:
 tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark
 ```
 
-## Architecture & Notes
+## Architecture & Features
 
 <Callout type="info">
-  - **Standalone sidecar**: The panel ships pre-compiled `tuic-server` musl binaries on Linux (amd64, arm64, armv7, 386) and executable for Windows.
-  - **Traffic accounting & limits**: The panel owns the inbound's public UDP port with a small relay and runs `tuic-server` behind it on a loopback port, so the inbound's upload and download bytes are counted exactly on every OS and enforced at the **inbound level** (`inbounds.total`); `tuic-server` therefore logs `127.0.0.1` as every client's address. Because upstream `tuic-server` does not provide an internal per-user metrics API, individual client traffic limits (`totalGB`) are not supported for TUIC clients. Client access can be controlled via expiration timestamps (`expiryTime`) and manual enable/disable toggles.
-  - **Online status & "start after first use"**: The panel detects a client's activity from the sidecar's Info log lines (they carry the client UUID), so those features need the inbound's log level at `info` or `debug`; `warn` and `error` silence them.
-  - **Client updates & connections**: Because upstream `tuic-server` lacks dynamic user reload APIs, client modifications (adding, updating, or disabling clients) restart the sidecar process and momentarily reset active connections.
-  - **Deployment**: Because TUIC operates via a host sidecar process, TUIC inbounds are panel-local (main instance).
+  - **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
+  - **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
+  - **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
+  - **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
+  - **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
 </Callout>

+ 1 - 1
docs/content/docs/fa/config/clients.mdx

@@ -18,7 +18,7 @@ icon: Users
 | **Auth**       | Hysteria2             | اعتبارنامه‌ی کلاینت.                                                |
 | **Flow**       | VLESS                 | جریان XTLS، برای مثال `xtls-rprx-vision`.                          |
 | **Limit IP**   | همه (به‌جز TUIC)      | بیشینه‌ی تعداد IPهای مبدأ هم‌زمان (با Fail2ban اعمال می‌شود).       |
-| **Total (GB)** | همه (به‌جز TUIC)      | سهمیه‌ی ترافیک؛ هنگام اتمام، کلاینت غیرفعال می‌شود (برای TUIC محدودیت در سطح ورودی تعیین می‌شود). |
+| **Total (GB)** | همه                   | سهمیه‌ی ترافیک؛ هنگام اتمام، کلاینت غیرفعال می‌شود.                 |
 | **Expiry**     | همه                   | تاریخی که پس از آن کلاینت از کار می‌افتد.                          |
 | **Reset**      | همه                   | دوره‌ی تمدید خودکار به **روز** (سهمیه را از نو می‌چرخاند).          |
 | **Telegram ID**| همه                   | کلاینت را به یک کاربر Telegram برای سلف‌سرویس/اعلان‌ها پیوند می‌دهد.|

+ 1 - 1
docs/content/docs/fa/config/inbounds.mdx

@@ -64,7 +64,7 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
 | **Mixed (SOCKS/HTTP)** | یک شنونده ترکیبی SOCKS + HTTP.                                            |
 | **Dokodemo-door / Tunnel** | فورواردینگ پورت / هدایت ترافیک.                                      |
 | **MTProto**            | پراکسی MTProto تلگرام که توسط یک فرایند همراه `mtg` سرویس می‌شود (نه Xray). |
-| **TUIC**               | پروتکل پراکسی مبتنی بر QUIC نسخه ۵ که توسط فرایند `tuic-server` ارائه می‌شود. مشاهده [TUIC](/docs/config/tuic). |
+| **TUIC**               | پروتکل پراکسی مبتنی بر QUIC نسخه ۵ که به صورت سرور بومی Go درون فرایند ارائه می‌شود. مشاهده [TUIC](/docs/config/tuic). |
 
 <Callout type="info">
   Hysteria2 در سطح داخلی یک پروتکل جداگانه نیست — همان پروتکل `hysteria` است که

+ 1 - 1
docs/content/docs/ru/config/clients.mdx

@@ -19,7 +19,7 @@ icon: Users
 | **Auth**       | Hysteria2             | Учётные данные клиента.                                            |
 | **Flow**       | VLESS                 | Поток XTLS, например `xtls-rprx-vision`.                           |
 | **Limit IP**   | все (кроме TUIC)      | Максимум одновременных IP-адресов источника (контролируется через Fail2ban). |
-| **Total (GB)** | все (кроме TUIC)      | Квота трафика; при исчерпании клиент отключается (для TUIC лимит задаётся на уровне инбаунда). |
+| **Total (GB)** | все                   | Квота трафика; при исчерпании клиент отключается.                  |
 | **Expiry**     | все                   | Дата, после которой клиент перестаёт работать.                    |
 | **Reset**      | все                   | Период автопродления в **днях** (обнуляет квоту).                 |
 | **Telegram ID**| все                   | Привязывает клиента к пользователю Telegram для самообслуживания/уведомлений.|

+ 1 - 1
docs/content/docs/ru/config/inbounds.mdx

@@ -65,7 +65,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). |
+| **TUIC**               | Протокол проксирования на базе QUIC (v5), обслуживаемый встроенным сервером на Go. См. [TUIC](/docs/config/tuic). |
 
 <Callout type="info">
   Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria`

+ 8 - 11
docs/content/docs/ru/config/tuic.mdx

@@ -9,10 +9,7 @@ icon: Zap
 и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
 
 <Callout type="info">
-  Как и MTProto, TUIC работает как **изолированный процесс-сайдкар** (`tuic-server` 1.0.0,
-  написан на Rust), а не внутри Xray-core. Панель управляет жизненным циклом бинарника,
-  генерирует конфигурации, отслеживает его состояние, фиксирует общий трафик инбаунда
-  и онлайн-активность клиентов.
+  TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и горячего обновления пользователей без обрыва соединений.
 </Callout>
 
 ## Ключевые параметры
@@ -24,7 +21,7 @@ icon: Zap
 | **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
 | **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
 | **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. |
-| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. |
+| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. Сервер работает с `bbr` или `new_reno`; `cubic` передаётся клиентам, но на сервере применяется как `new_reno`. |
 | **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
 | **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. |
 | **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
@@ -101,12 +98,12 @@ proxies:
 tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark
 ```
 
-## Архитектура и примечания
+## Архитектура и возможности
 
 <Callout type="info">
-  - **Автономный сайдкар**: Панель поставляется со скомпилированными статическими `musl`-бинарниками `tuic-server` для Linux (amd64, arm64, armv7, 386) и исполняемым файлом для Windows.
-  - **Учёт трафика и лимиты**: Панель сама занимает публичный UDP-порт инбаунда небольшим relay и запускает `tuic-server` за ним на loopback-порту, поэтому входящие и исходящие байты инбаунда считаются точно на любой ОС и ограничиваются на **уровне инбаунда** (`inbounds.total`); в логах `tuic-server` адресом каждого клиента будет `127.0.0.1`. Поскольку апстрим `tuic-server` не предоставляет внутреннего API метрик по отдельным пользователям, персональные квоты трафика (`totalGB`) для клиентов TUIC не поддерживаются. Доступ клиентов контролируется по сроку действия (`expiryTime`) и переключателю активности.
-  - **Статус онлайн и «старт после первого использования»**: Панель определяет активность клиента по строкам Info в логе сайдкара (в них есть UUID клиента), поэтому этим функциям нужен уровень логов `info` или `debug`; `warn` и `error` их отключают.
-  - **Изменения клиентов и соединения**: Поскольку апстрим `tuic-server` не поддерживает динамическую перезагрузку пользователей без перезапуска, любое изменение списка клиентов (добавление, редактирование или отключение) перезапускает процесс сайдкара и кратковременно сбрасывает активные соединения.
-  - **Развёртывание**: Поскольку TUIC управляется локальным процессом хоста, такие инбаунды работают локально на главной панели.
+  - **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
+  - **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
+  - **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
+  - **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
+  - **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
 </Callout>

+ 1 - 1
docs/content/docs/zh/config/clients.mdx

@@ -17,7 +17,7 @@ icon: Users
 | **Auth**       | Hysteria2             | 客户端凭据。                                                      |
 | **Flow**       | VLESS                 | XTLS 流控,例如 `xtls-rprx-vision`。                              |
 | **Limit IP**   | 全部(TUIC 除外)      | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。             |
-| **Total (GB)** | 全部(TUIC 除外)      | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
+| **Total (GB)** | 全部                  | 流量配额;用尽后客户端将被禁用。                                   |
 | **Expiry**     | 全部                  | 该日期之后客户端停止工作。                                       |
 | **自动续期**   | 全部                  | 关闭、固定天数、日历每周或日历每月。                             |
 | **Telegram ID**| 全部                  | 将客户端关联到 Telegram 用户,用于自助服务/通知。               |

+ 1 - 1
docs/content/docs/zh/config/inbounds.mdx

@@ -61,7 +61,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)。 |
+| **TUIC**               | 基于 QUIC 的代理协议(v5),由进程内原生 Go 服务器提供。参见 [TUIC](/docs/config/tuic)。 |
 
 <Callout type="info">
   在内部,Hysteria2 并不是一个独立的协议——它是把传输版本设为 2 的 `hysteria`

+ 35 - 0
frontend/src/lib/tuic.ts

@@ -0,0 +1,35 @@
+export type TuicCongestionController = 'bbr' | 'cubic' | 'new_reno';
+
+export function normalizeTuicCongestionController(value: unknown): TuicCongestionController {
+  if (typeof value !== 'string' || value.trim() === '') return 'bbr';
+
+  switch (value.trim().toLowerCase()) {
+    case 'bbr':
+      return 'bbr';
+    case 'cubic':
+      return 'cubic';
+    case 'reno':
+    case 'new_reno':
+      return 'new_reno';
+    default:
+      return 'new_reno';
+  }
+}
+
+export function resolveTuicServerSettings(
+  settings: Record<string, unknown>,
+): Record<string, unknown> {
+  const nested =
+    settings.server && typeof settings.server === 'object' && !Array.isArray(settings.server)
+      ? (settings.server as Record<string, unknown>)
+      : {};
+  const result: Record<string, unknown> = { ...settings };
+  delete result.server;
+  delete result.clients;
+  for (const [key, value] of Object.entries(nested)) {
+    if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) continue;
+    if (typeof value === 'number' && value <= 0) continue;
+    result[key] = value;
+  }
+  return result;
+}

+ 7 - 1
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -1,3 +1,4 @@
+import { resolveTuicServerSettings } from '@/lib/tuic';
 import type {
   InboundFormValues,
   ShareAddrStrategy,
@@ -168,7 +169,12 @@ function stripTlsCertUseFile(stream: Record<string, unknown>): void {
 
 export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
   const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
-  const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
+  const rawSettings = coerceJsonObject(row.settings);
+  const settings = (
+    protocol === 'tuic'
+      ? { clients: rawSettings.clients, server: resolveTuicServerSettings(rawSettings) }
+      : rawSettings
+  ) as InboundSettings['settings'];
   const rawStream = coerceJsonObject(row.streamSettings);
   const streamSettings =
     Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;

+ 5 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -17,6 +17,7 @@ import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { getHeaderValue } from './headers';
 import { canEnableTlsFlow } from './protocol-capabilities';
 import { deriveSpiderX } from './spider-x';
+import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
 
 // Share-link generators. Each per-protocol fn takes a typed inbound plus
 // client overrides and returns a URL (or '' when the protocol doesn't
@@ -914,15 +915,16 @@ export function genTuicLink(input: GenTuicLinkInput): string {
   if (!clientUuid || !clientPassword) return '';
 
   const rawSettings = inbound.settings as Record<string, unknown>;
-  const server = (rawSettings.server as Record<string, unknown>) ?? rawSettings;
+  const server = resolveTuicServerSettings(rawSettings);
   const host = formatUrlHost(externalProxy?.dest || address);
   const targetPort = externalProxy?.port || port;
 
   const url = new URL(
     `tuic://${encodeURIComponent(clientUuid)}:${encodeURIComponent(clientPassword)}@${host}:${targetPort}`,
   );
-  const cc =
-    (server.congestion_control as string) || (rawSettings.congestion_control as string) || 'bbr';
+  const cc = normalizeTuicCongestionController(
+    server.congestion_control ?? rawSettings.congestion_control,
+  );
   url.searchParams.set('congestion_control', cc);
 
   const epAlpn = externalProxyAlpn(externalProxy?.alpn);

+ 5 - 1
frontend/src/lib/xray/node-protocols.ts

@@ -2,7 +2,8 @@ import { Protocols } from '@/schemas/primitives';
 
 /*
  * Protocols whose inbounds can live on a sub-node (the "Deploy To" set).
- * Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only.
+ * Everything else (http, mixed, tunnel, tun) is panel-local only. The sidecar
+ * protocols run on the node's own panel; the backend refuses a node too old.
  * Shared by the inbound form's Deploy To selector and the clone dialog's
  * target picker so the two surfaces can never drift apart.
  */
@@ -13,4 +14,7 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
   [Protocols.SHADOWSOCKS]: true,
   [Protocols.HYSTERIA]: true,
   [Protocols.WIREGUARD]: true,
+  [Protocols.MTPROTO]: true,
+  [Protocols.AMNEZIAWG]: true,
+  [Protocols.TUIC]: true,
 };

+ 6 - 4
frontend/src/lib/xray/spider-x.ts

@@ -1,10 +1,12 @@
 import { sha256 } from '@noble/hashes/sha2.js';
 import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
 
-// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
-// links and subscription links agree; returns '' when there is no seed and
-// no client key (the caller then omits spx, as the legacy builder did).
+// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte, seed query included (#6693);
+// '' with neither seed nor client key, so the caller omits spx as the legacy builder did.
 export function deriveSpiderX(seed: string, clientKey: string): string {
   if (!seed && !clientKey) return '';
-  return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
+  const path = `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
+  const at = seed.indexOf('?');
+  const query = at === -1 ? '' : seed.slice(at + 1);
+  return query ? `${path}?${query}` : path;
 }

+ 1 - 16
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -130,19 +130,6 @@ export default function ClientBulkAddModal({
     return '';
   }, [inboundIds, inbounds]);
 
-  const tuicIds = useMemo(() => {
-    const ids = new Set<number>();
-    for (const row of inbounds || []) {
-      if (row && row.protocol === 'tuic') ids.add(row.id);
-    }
-    return ids;
-  }, [inbounds]);
-
-  const hasTuic = useMemo(
-    () => (inboundIds || []).some((id) => tuicIds.has(id)),
-    [inboundIds, tuicIds],
-  );
-
   useEffect(() => {
     if (!showFlow && flow) {
       methods.setValue('flow', '');
@@ -405,9 +392,7 @@ export default function ClientBulkAddModal({
             <FormField
               name="totalGB"
               label={t('pages.clients.totalGB')}
-              tooltip={
-                hasTuic ? t('pages.clients.tuicTotalGBDesc') : t('pages.clients.totalGBDesc')
-              }
+              tooltip={t('pages.clients.totalGBDesc')}
               transform={{ output: (v) => Number(v) || 0 }}
             >
               <InputNumber min={0} step={1} />

+ 1 - 18
frontend/src/pages/clients/ClientFormModal.tsx

@@ -452,19 +452,6 @@ export default function ClientFormModal({
     return ids;
   }, [inbounds]);
 
-  const tuicIds = useMemo(() => {
-    const ids = new Set<number>();
-    for (const row of inbounds || []) {
-      if (row && row.protocol === 'tuic') ids.add(row.id);
-    }
-    return ids;
-  }, [inbounds]);
-
-  const hasTuic = useMemo(
-    () => (inboundIds || []).some((id) => tuicIds.has(id)),
-    [inboundIds, tuicIds],
-  );
-
   const mtprotoDomain = useMemo(() => {
     for (const id of inboundIds || []) {
       const ib = (inbounds || []).find((row) => row.id === id);
@@ -884,11 +871,7 @@ export default function ClientFormModal({
                           <FormField
                             name="totalGB"
                             label={t('pages.clients.totalGB')}
-                            tooltip={
-                              hasTuic
-                                ? t('pages.clients.tuicTotalGBDesc')
-                                : t('pages.clients.totalGBDesc')
-                            }
+                            tooltip={t('pages.clients.totalGBDesc')}
                             transform={{ output: (v) => Number(v) || 0 }}
                           >
                             <InputNumber min={0} step={1} style={{ width: '100%' }} />

+ 2 - 1
frontend/src/pages/clients/tuicConfig.ts

@@ -1,6 +1,7 @@
 import type { HostEndpoint } from '@/lib/hosts/host-link';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
+import { normalizeTuicCongestionController } from '@/lib/tuic';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 
 export function isTuicClient(client: ClientRecord | null | undefined): boolean {
@@ -39,7 +40,7 @@ export function buildTuicClientConfig(
       ? tuicServer.alpn
       : ['h3', 'spdy/3.1'];
   const sni = hostEndpoint?.sni || tuicServer?.sni || endpointHost;
-  const cc = tuicServer?.congestion_control || 'bbr';
+  const cc = normalizeTuicCongestionController(tuicServer?.congestion_control);
   const udpRelay = tuicServer?.udp_relay_mode || 'native';
   const reduceRtt = tuicServer?.zero_rtt_handshake ?? true;
 

+ 19 - 29
frontend/src/pages/inbounds/form/protocols/tuic.tsx

@@ -4,6 +4,7 @@ import {
   AutoComplete,
   Button,
   Collapse,
+  Divider,
   Form,
   Input,
   InputNumber,
@@ -20,7 +21,7 @@ import { HttpUtil } from '@/utils';
 
 export default function TuicFields() {
   const { t } = useTranslation();
-  const { control, setValue, getValues } = useFormContext();
+  const { control, setValue } = useFormContext();
   const [loadingPanelCert, setLoadingPanelCert] = useState(false);
 
   const sni = (useWatch({ control, name: 'settings.server.sni' }) ?? '') as string;
@@ -30,24 +31,12 @@ export default function TuicFields() {
 
   const handleSniChange = (newSni: string) => {
     setValue('settings.server.sni', newSni);
-    const cleanSni = newSni.trim();
-    if (!cleanSni) return;
-
-    const currentCert = String(getValues('settings.server.certificate') || '');
-    const currentKey = String(getValues('settings.server.private_key') || '');
-
-    if (!currentCert || currentCert.startsWith('/root/cert/')) {
-      setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
-    }
-    if (!currentKey || currentKey.startsWith('/root/cert/')) {
-      setValue('settings.server.private_key', `/root/cert/${cleanSni}/privkey.pem`);
-    }
   };
 
   const autofillFromSni = () => {
     const cleanSni = (sni || '').trim();
     if (!cleanSni) {
-      message.warning(t('pages.xray.tuic.sniHint'));
+      message.warning(t('pages.xray.tuic.sniRequired'));
       return;
     }
     setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
@@ -147,7 +136,7 @@ export default function TuicFields() {
             name={['settings', 'server', 'max_udp_relay_packet_size']}
             label={t('pages.xray.tuic.maxUdpRelayPacketSize')}
           >
-            <InputNumber min={1} style={{ width: '100%' }} />
+            <InputNumber min={1} max={65245} style={{ width: '100%' }} />
           </FormField>
         </>
       ),
@@ -156,20 +145,6 @@ export default function TuicFields() {
 
   return (
     <>
-      <Form.Item label={t('pages.xray.tuic.sni')}>
-        <Space.Compact style={{ display: 'flex' }}>
-          <Input
-            value={sni}
-            placeholder="example.com"
-            onChange={(e) => handleSniChange(e.target.value)}
-            style={{ flex: 1 }}
-          />
-          <Button icon={<SyncOutlined />} onClick={autofillFromSni}>
-            {t('pages.inbounds.form.autoFill')}
-          </Button>
-        </Space.Compact>
-      </Form.Item>
-
       <Form.Item label={t('pages.inbounds.publicKey')}>
         <AutoComplete
           value={certificate}
@@ -198,6 +173,9 @@ export default function TuicFields() {
           >
             {t('pages.inbounds.setDefaultCert')}
           </Button>
+          <Button icon={<SyncOutlined />} onClick={autofillFromSni}>
+            {t('pages.inbounds.form.autoFill')}
+          </Button>
           <Button
             danger
             onClick={() => {
@@ -213,6 +191,7 @@ export default function TuicFields() {
       <FormField
         name={['settings', 'server', 'congestion_control']}
         label={t('pages.xray.tuic.congestionControl')}
+        tooltip={t('pages.xray.tuic.congestionControlHint')}
       >
         <Select
           options={[
@@ -234,9 +213,20 @@ export default function TuicFields() {
         />
       </FormField>
 
+      <Divider titlePlacement="start">{t('pages.xray.tuic.profileOptions')}</Divider>
+
+      <Form.Item label={t('pages.xray.tuic.sni')} tooltip={t('pages.xray.tuic.sniHint')}>
+        <Input
+          value={sni}
+          placeholder="example.com"
+          onChange={(e) => handleSniChange(e.target.value)}
+        />
+      </Form.Item>
+
       <FormField
         name={['settings', 'server', 'udp_relay_mode']}
         label={t('pages.xray.tuic.udpRelayMode')}
+        tooltip={t('pages.xray.tuic.udpRelayModeHint')}
       >
         <Select
           options={[

+ 25 - 5
frontend/src/schemas/protocols/inbound/tuic.ts

@@ -1,11 +1,23 @@
 import { z } from 'zod';
 
+import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
+
 const optionalClearedInt = (schema: z.ZodNumber) =>
   z.preprocess((v) => (v == null ? undefined : v), schema.optional());
 
 const clearedToDefault = <T extends z.ZodType>(schema: T) =>
   z.preprocess((v) => (v == null ? undefined : v), schema);
 
+const congestionController = z.preprocess(
+  normalizeTuicCongestionController,
+  z.enum(['bbr', 'cubic', 'new_reno']),
+);
+
+const maxUdpRelayPacketSize = z.preprocess(
+  (value) => (typeof value === 'number' && value > 65245 && value <= 65507 ? 65245 : value),
+  z.number().int().min(1).max(65245),
+);
+
 export const TuicClientSchema = z.object({
   uuid: z.string().optional(),
   id: z.string().optional(),
@@ -30,31 +42,39 @@ export type TuicClient = z.infer<typeof TuicClientSchema>;
 export const TuicServerSchema = z.object({
   certificate: z.string().default(''),
   private_key: z.string().default(''),
-  congestion_control: z.enum(['bbr', 'cubic', 'new_reno']).default('bbr'),
+  congestion_control: congestionController.default('bbr'),
   alpn: z.array(z.string()).default(['h3', 'spdy/3.1']),
   udp_relay_mode: z.enum(['native', 'quic']).default('native'),
   zero_rtt_handshake: z.boolean().default(true),
   log_level: z.enum(['info', 'warn', 'error', 'debug']).default('info'),
   max_idle_time: clearedToDefault(z.number().int().min(1).default(15)),
   authentication_timeout: clearedToDefault(z.number().int().min(1).default(3)),
-  max_udp_relay_packet_size: clearedToDefault(z.number().int().min(1).default(1500)),
+  max_udp_relay_packet_size: clearedToDefault(maxUdpRelayPacketSize.default(1500)),
   sni: z.string().default(''),
 });
 export type TuicServer = z.infer<typeof TuicServerSchema>;
 
-export const TuicInboundSettingsSchema = z.object({
+const TuicSettingsObject = z.object({
   server: TuicServerSchema.optional(),
   certificate: z.string().optional(),
   private_key: z.string().optional(),
-  congestion_control: z.string().optional(),
+  congestion_control: congestionController.optional(),
   alpn: z.array(z.string()).optional(),
   udp_relay_mode: z.string().optional(),
   zero_rtt_handshake: z.boolean().optional(),
   log_level: z.string().optional(),
   max_idle_time: optionalClearedInt(z.number().int().min(1)),
   authentication_timeout: optionalClearedInt(z.number().int().min(1)),
-  max_udp_relay_packet_size: optionalClearedInt(z.number().int().min(1)),
+  max_udp_relay_packet_size: z.preprocess(
+    (value) => (typeof value === 'number' && value > 65245 && value <= 65507 ? 65245 : value),
+    optionalClearedInt(z.number().int().min(1).max(65245)),
+  ),
   sni: z.string().optional(),
   clients: z.array(TuicClientSchema).default([]),
 });
+export const TuicInboundSettingsSchema = z.preprocess((value) => {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
+  const raw = value as Record<string, unknown>;
+  return { ...raw, server: resolveTuicServerSettings(raw) };
+}, TuicSettingsObject);
 export type TuicInboundSettings = z.infer<typeof TuicInboundSettingsSchema>;

+ 42 - 0
frontend/src/test/inbound-defaults.test.ts

@@ -178,6 +178,48 @@ describe('createDefault*InboundSettings factories', () => {
   });
 });
 
+describe('TuicInboundSettingsSchema', () => {
+  it('canonicalizes congestion-controller aliases and casing', () => {
+    expect(
+      TuicInboundSettingsSchema.parse({ server: { congestion_control: 'RENO' } }).server
+        ?.congestion_control,
+    ).toBe('new_reno');
+    expect(
+      TuicInboundSettingsSchema.parse({ server: { congestion_control: ' CuBiC ' } }).server
+        ?.congestion_control,
+    ).toBe('cubic');
+    expect(
+      TuicInboundSettingsSchema.parse({ server: { congestion_control: '' } }).server
+        ?.congestion_control,
+    ).toBe('bbr');
+    expect(
+      TuicInboundSettingsSchema.parse({ server: { congestion_control: '  ' } }).server
+        ?.congestion_control,
+    ).toBe('bbr');
+    expect(
+      TuicInboundSettingsSchema.parse({ congestion_control: ' CuBiC ' }).congestion_control,
+    ).toBe('cubic');
+    expect(
+      TuicInboundSettingsSchema.parse({ congestion_control: 'invalid' }).congestion_control,
+    ).toBe('new_reno');
+  });
+
+  it('clamps legacy packet-size values to the SOCKS-safe UDP payload maximum', () => {
+    expect(
+      TuicInboundSettingsSchema.parse({ server: { max_udp_relay_packet_size: 65507 } }).server
+        ?.max_udp_relay_packet_size,
+    ).toBe(65245);
+    expect(
+      TuicInboundSettingsSchema.parse({ max_udp_relay_packet_size: 65500 })
+        .max_udp_relay_packet_size,
+    ).toBe(65245);
+    expect(() =>
+      TuicInboundSettingsSchema.parse({ server: { max_udp_relay_packet_size: 65508 } }),
+    ).toThrow();
+    expect(() => TuicInboundSettingsSchema.parse({ max_udp_relay_packet_size: 65508 })).toThrow();
+  });
+});
+
 describe('createHysteriaTlsSettingsWithDefaultCert', () => {
   it('defaults Hysteria TLS to uTLS None and h3 ALPN', () => {
     const tls = createHysteriaTlsSettingsWithDefaultCert();

+ 35 - 0
frontend/src/test/inbound-link.test.ts

@@ -1232,6 +1232,41 @@ describe('genVlessLink XHTTP extra compatibility', () => {
 });
 
 describe('genTuicLink', () => {
+  it('canonicalizes legacy flat controller values to the Go runtime default', () => {
+    const cases = [
+      { value: '', expected: 'bbr' },
+      { value: ' ', expected: 'bbr' },
+      { value: 'BBR', expected: 'bbr' },
+      { value: ' CuBiC ', expected: 'cubic' },
+      { value: 'reno', expected: 'new_reno' },
+      { value: 'invalid', expected: 'new_reno' },
+    ];
+    for (const { value, expected } of cases) {
+      const inbound = InboundSchema.parse({
+        id: 10,
+        protocol: 'tuic',
+        port: 8443,
+        settings: {
+          congestion_control: value,
+          clients: [
+            {
+              uuid: '11111111-2222-3333-4444-555555555555',
+              password: 'secretpassword',
+              email: 'user@tuic',
+            },
+          ],
+        },
+      });
+      const link = genTuicLink({
+        inbound,
+        address: 'example.com',
+        clientUuid: '11111111-2222-3333-4444-555555555555',
+        clientPassword: 'secretpassword',
+      });
+      expect(new URL(link).searchParams.get('congestion_control')).toBe(expected);
+    }
+  });
+
   it('builds a standard tuic share link with all parameters', () => {
     const inbound = InboundSchema.parse({
       id: 1,

+ 6 - 0
frontend/src/test/spider-x.test.ts

@@ -9,6 +9,12 @@ describe('deriveSpiderX', () => {
   it('matches the Go deriveSpiderX vectors', () => {
     expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
     expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
+    expect(deriveSpiderX('/seed?p=40-400&r=500-2000', 'subAlice')).toBe(
+      '/09dd00b3f8c01f5?p=40-400&r=500-2000',
+    );
+    expect(deriveSpiderX('/?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000', '')).toBe(
+      '/ac2cb268d22908e?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000',
+    );
   });
 
   it('is stable per client, distinct across clients, and rotates with the seed', () => {

+ 48 - 0
frontend/src/test/tuic-client-config.test.ts

@@ -52,6 +52,16 @@ describe('buildTuicClientConfig', () => {
     expect(cfg).toContain('sni: server.example.com');
   });
 
+  it('exports canonical controller values for legacy settings', () => {
+    const legacyInbound = {
+      ...inbound,
+      tuicServer: { ...inbound.tuicServer, congestion_control: ' RENO ' },
+    } as InboundOption;
+    const cfg = buildTuicClientConfig(client, legacyInbound, 'server.example.com', '');
+    expect(cfg).toContain('congestion-controller: new_reno');
+    expect(cfg).not.toContain('congestion-controller:  RENO ');
+  });
+
   it('escapes quotes in passwords and remarks', () => {
     const dangerousClient: ClientRecord = {
       ...client,
@@ -60,4 +70,42 @@ describe('buildTuicClientConfig', () => {
     const cfg = buildTuicClientConfig(dangerousClient, inbound, 'server.example.com', '');
     expect(cfg).toContain('password: "pass\\"with\\"quotes\\nnewline"');
   });
+
+  it.each([
+    ['bbr', 'bbr'],
+    ['cubic', 'cubic'],
+    [' RENO ', 'new_reno'],
+    ['unknown', 'new_reno'],
+  ])('keeps controller %s and relay settings when applying a Host', (controller, expected) => {
+    const config = buildTuicClientConfig(
+      client,
+      {
+        ...inbound,
+        tuicServer: {
+          ...inbound.tuicServer,
+          congestion_control: controller,
+          udp_relay_mode: 'quic',
+          zero_rtt_handshake: false,
+        },
+      } as InboundOption,
+      'panel.example.com',
+      '',
+      {
+        dest: 'edge.example.com',
+        port: 9443,
+        remark: 'Edge',
+        sni: 'edge.sni.example.com',
+        alpn: ['h3'],
+        allowInsecure: true,
+      },
+    );
+    expect(config).toContain('server: edge.example.com');
+    expect(config).toContain('port: 9443');
+    expect(config).toContain('sni: edge.sni.example.com');
+    expect(config).toContain('alpn:\n      - h3\n');
+    expect(config).toContain(`congestion-controller: ${expected}`);
+    expect(config).toContain('udp-relay-mode: quic');
+    expect(config).toContain('reduce-rtt: false');
+    expect(config).toContain('skip-cert-verify: true');
+  });
 });

+ 80 - 0
frontend/src/test/tuic-legacy-form.test.tsx

@@ -0,0 +1,80 @@
+import { useEffect } from 'react';
+import { describe, expect, it } from 'vitest';
+import { FormProvider, useForm, type UseFormReturn } from 'react-hook-form';
+import { render, act } from '@testing-library/react';
+import { Form } from 'antd';
+import TuicFields from '@/pages/inbounds/form/protocols/tuic';
+import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
+import { InboundFormSchema, type InboundFormValues } from '@/schemas/forms/inbound-form';
+import { genTuicLink } from '@/lib/xray/inbound-link';
+
+const legacy = {
+  certificate: '/old/cert.pem',
+  private_key: '/old/key.pem',
+  congestion_control: 'cubic',
+  log_level: 'error',
+  udp_relay_mode: 'quic',
+  sni: 'old.example',
+  zero_rtt_handshake: false,
+  max_idle_time: 77,
+  authentication_timeout: 11,
+  max_udp_relay_packet_size: 8192,
+};
+const row = { protocol: 'tuic', port: 8443, settings: legacy };
+let methods: UseFormReturn<InboundFormValues>;
+function Harness() {
+  const form = useForm<InboundFormValues>({ defaultValues: rawInboundToFormValues(row) as never });
+  useEffect(() => {
+    methods = form;
+  }, [form]);
+  return (
+    <FormProvider {...form}>
+      <Form>
+        <TuicFields />
+      </Form>
+    </FormProvider>
+  );
+}
+
+describe('TUIC legacy edit and raw profile precedence', () => {
+  it('mounting old flat settings must display current certificate', () => {
+    render(<Harness />);
+    const displayed = Array.from(document.querySelectorAll('input')).map((x) => x.value);
+    expect(displayed).toContain('/old/cert.pem');
+  });
+  it('editing only SNI must preserve controller and runtime values', () => {
+    render(<Harness />);
+    act(() => methods.setValue('settings.server.sni', 'new.example'));
+    const parsed = InboundFormSchema.parse(methods.getValues());
+    const saved = JSON.parse(formValuesToWirePayload(parsed).settings);
+    expect.soft(saved.server.congestion_control).toBe('cubic');
+    expect.soft(saved.server.log_level).toBe('error');
+    expect.soft(saved.server.zero_rtt_handshake).toBe(false);
+    expect.soft(saved.server.max_idle_time).toBe(77);
+    expect.soft(saved.server.udp_relay_mode).toBe('quic');
+    expect.soft(saved.server.authentication_timeout).toBe(11);
+    expect.soft(saved.server.max_udp_relay_packet_size).toBe(8192);
+  });
+  it('mounting then saving without TUIC changes must preserve flat values', () => {
+    render(<Harness />);
+    const saved = JSON.parse(
+      formValuesToWirePayload(InboundFormSchema.parse(methods.getValues())).settings,
+    );
+    expect.soft(saved.server?.congestion_control ?? saved.congestion_control).toBe('cubic');
+    expect.soft(saved.server?.sni || saved.sni).toBe('old.example');
+    expect.soft(saved.server?.udp_relay_mode ?? saved.udp_relay_mode).toBe('quic');
+  });
+  it('empty nested controller must match runtime legacy flat fallback', () => {
+    const link = genTuicLink({
+      inbound: {
+        protocol: 'tuic',
+        port: 8443,
+        settings: { ...legacy, server: { congestion_control: '' } },
+      } as never,
+      address: 'proxy.example',
+      clientUuid: '11111111-1111-1111-1111-111111111111',
+      clientPassword: 'dummy',
+    });
+    expect(new URL(link).searchParams.get('congestion_control')).toBe('cubic');
+  });
+});

+ 2 - 2
go.mod

@@ -4,6 +4,7 @@ go 1.27.1
 
 require (
 	github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828
+	github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e
 	github.com/gin-contrib/gzip v1.2.8
 	github.com/gin-contrib/sessions v1.1.2
 	github.com/gin-gonic/gin v1.12.0
@@ -20,6 +21,7 @@ require (
 	github.com/mymmrac/telego v1.12.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
+	github.com/quic-go/quic-go v0.63.0
 	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.26.9
@@ -45,7 +47,6 @@ require (
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
 	github.com/andybalholm/brotli v1.2.6 // indirect
-	github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/sonic v1.15.4 // indirect
 	github.com/bytedance/sonic/loader v0.5.2 // indirect
@@ -92,7 +93,6 @@ require (
 	github.com/pires/go-proxyproto v0.15.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
-	github.com/quic-go/quic-go v0.63.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect

+ 4 - 32
install.sh

@@ -367,32 +367,6 @@ install_acme() {
     return 0
 }
 
-install_tuic_server() {
-    local target_arch=""
-    case "$(arch)" in
-        amd64|x86_64) target_arch="x86_64-unknown-linux-musl" ;;
-        arm64|aarch64) target_arch="aarch64-unknown-linux-musl" ;;
-        armv7|armv7l) target_arch="armv7-unknown-linux-musleabihf" ;;
-        386|i386|i686) target_arch="i686-unknown-linux-musl" ;;
-        armv6|armv6l|armv5|armv5l|s390x)
-            echo -e "${yellow}tuic-server does not provide prebuilt binaries for $(arch); TUIC inbounds will be unavailable on this machine${plain}"
-            return 0
-            ;;
-        *) return 0 ;;
-    esac
-
-    local tuic_url="https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-${target_arch}"
-    echo -e "${green}Installing tuic-server (${target_arch})...${plain}"
-    mkdir -p "${xui_folder}/bin"
-    if curl -fLR --connect-timeout 15 --retry 3 -o "${xui_folder}/bin/tuic-server" "${tuic_url}" && [[ -s "${xui_folder}/bin/tuic-server" ]]; then
-        chmod +x "${xui_folder}/bin/tuic-server"
-        echo -e "${green}tuic-server installed successfully${plain}"
-    else
-        rm -f "${xui_folder}/bin/tuic-server"
-        echo -e "${yellow}Failed to download tuic-server (optional), skipping${plain}"
-    fi
-}
-
 setup_ssl_certificate() {
     local domain="$1"
     local server_ip="$2"
@@ -1661,11 +1635,6 @@ install_x-ui() {
     elif [[ -f bin/mtg-linux-$(arch) ]]; then
         chmod +x bin/mtg-linux-$(arch)
     fi
-    if [[ -f bin/tuic-server ]]; then
-        chmod +x bin/tuic-server
-    else
-        install_tuic_server
-    fi
 
     # Restore anything from the old bin/ that the fresh release doesn't ship
     # (custom geoip/geosite files, or anything else an admin hand-placed
@@ -1684,7 +1653,7 @@ install_x-ui() {
         while IFS= read -r -d '' f; do
             local rel="${f#"${custom_bin_backup}"/}"
             case "${rel}" in
-                config.json | mtproto | mtproto/* | tuic | tuic/*) continue ;;
+                config.json | mtproto | mtproto/* | tuic | tuic/* | tuic-server | tuic-server-*) continue ;;
             esac
             if [[ ! -e "bin/${rel}" ]]; then
                 mkdir -p "bin/$(dirname "${rel}")"
@@ -1700,6 +1669,9 @@ install_x-ui() {
     fi
     trap - EXIT INT TERM
 
+    rm -f bin/tuic-server bin/tuic-server-* > /dev/null 2>&1 || true
+    rm -rf bin/tuic > /dev/null 2>&1 || true
+
     # Update x-ui cli and se set permission
     mv -f "${xui_script_temp}" /usr/bin/x-ui
     if [[ $? -ne 0 ]]; then

+ 1 - 2
internal/amneziawgnet/bench_test.go

@@ -8,7 +8,6 @@ import (
 	"testing"
 	"time"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
 	"gvisor.dev/gvisor/pkg/buffer"
@@ -159,7 +158,7 @@ func newBenchTunnel(b *testing.B, listenPort int, serverAddr, clientAddr string)
 	if err != nil {
 		b.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
 	if err != nil {

+ 3 - 4
internal/amneziawgnet/device_test.go

@@ -10,7 +10,6 @@ import (
 	"testing"
 	"time"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
 	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
@@ -107,7 +106,7 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
@@ -314,7 +313,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
@@ -492,7 +491,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)

+ 1 - 2
internal/amneziawgnet/diagnostics_test.go

@@ -8,7 +8,6 @@ import (
 	"testing"
 	"time"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
 	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
@@ -111,7 +110,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)

+ 11 - 0
internal/amneziawgnet/main_test.go

@@ -0,0 +1,11 @@
+package amneziawgnet
+
+import (
+	"os"
+	"testing"
+)
+
+func TestMain(m *testing.M) {
+	wildcardBindHost = "127.0.0.1"
+	os.Exit(m.Run())
+}

+ 13 - 2
internal/amneziawgnet/pinned_bind.go

@@ -162,15 +162,26 @@ func newListenBind(listen string) awgconn.Bind {
 		if raw != "" && !isWildcardListen(raw) {
 			logger.Warningf("amneziawgnet: listen %q is not a bindable IP; using dual-stack wildcard", raw)
 		}
-		return awgconn.NewDefaultBind()
+		return wildcardBind()
 	}
 	if !listenBindable(addr) {
 		logger.Warningf("amneziawgnet: listen %q is not usable on this host; using dual-stack wildcard", raw)
-		return awgconn.NewDefaultBind()
+		return wildcardBind()
 	}
 	return newPinnedBind(addr)
 }
 
+// wildcardBindHost replaces "all interfaces" for every host socket this package
+// opens; TestMain pins it to loopback so Windows Firewall never prompts.
+var wildcardBindHost = ""
+
+func wildcardBind() awgconn.Bind {
+	if wildcardBindHost != "" {
+		return newPinnedBind(netip.MustParseAddr(wildcardBindHost))
+	}
+	return awgconn.NewDefaultBind()
+}
+
 // normalizedListenFP collapses wildcard spellings so fingerprint rebuilds
 // only when the effective Bind actually changes.
 func normalizedListenFP(listen string) string {

+ 3 - 0
internal/amneziawgnet/pinned_bind_test.go

@@ -74,6 +74,9 @@ func TestNewListenBindPinsSpecificAddress(t *testing.T) {
 }
 
 func TestNewListenBindWildcardUsesDefault(t *testing.T) {
+	prev := wildcardBindHost
+	wildcardBindHost = ""
+	t.Cleanup(func() { wildcardBindHost = prev })
 	for _, listen := range []string{"", "0.0.0.0", "::", "::0", "[::]", "hostname.example", "203.0.113.10", "not-an-ip"} {
 		bind := newListenBind(listen)
 		if _, ok := bind.(*pinnedBind); ok {

+ 2 - 2
internal/amneziawgnet/portfwd.go

@@ -23,9 +23,9 @@ package amneziawgnet
 
 import (
 	"context"
-	"fmt"
 	"net"
 	"net/netip"
+	"strconv"
 	"sync"
 	"time"
 
@@ -304,7 +304,7 @@ type tcpForwardListener struct {
 // result as "not open this round" and retries on every future Reconcile
 // call for as long as the key stays desired.
 func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *tcpForwardListener {
-	ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf(":%d", key.port))
+	ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", net.JoinHostPort(wildcardBindHost, strconv.Itoa(key.port)))
 	if err != nil {
 		logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
 		return nil

+ 2 - 3
internal/amneziawgnet/portfwd_test.go

@@ -10,7 +10,6 @@ import (
 	"testing"
 	"time"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
 	"gvisor.dev/gvisor/pkg/tcpip/stack"
@@ -190,7 +189,7 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
 
 	const collidingPort = 58911
 	const okPort = 58912
-	blocker, err := net.Listen("tcp", fmt.Sprintf(":%d", collidingPort))
+	blocker, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", collidingPort))
 	if err != nil {
 		t.Fatalf("pre-bind test port: %v", err)
 	}
@@ -284,7 +283,7 @@ func TestPortForwardRoundTripTCPAndUDP(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 	// clientDev.Close() closes the tun's packet channel without waiting for
 	// writers, so every goroutine writing into clientNet must be gone first.

+ 2 - 2
internal/amneziawgnet/portfwd_udp.go

@@ -2,9 +2,9 @@ package amneziawgnet
 
 import (
 	"context"
-	"fmt"
 	"net"
 	"net/netip"
+	"strconv"
 	"sync"
 	"time"
 
@@ -46,7 +46,7 @@ type udpForwardListener struct {
 // toward target(key.email). Bind-failure contract matches
 // listenPortForwardTCP exactly: log, return nil, Reconcile retries later.
 func listenPortForwardUDP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *udpForwardListener {
-	pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), "udp", fmt.Sprintf(":%d", key.port))
+	pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), "udp", net.JoinHostPort(wildcardBindHost, strconv.Itoa(key.port)))
 	if err != nil {
 		logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen udp :%d: %v", inboundID, key.email, key.port, err)
 		return nil

+ 2 - 3
internal/amneziawgnet/relay_e2e_test.go

@@ -13,7 +13,6 @@ import (
 	"testing"
 	"time"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
 	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
@@ -196,7 +195,7 @@ func TestSocksRelayAgainstRealXray(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
@@ -421,7 +420,7 @@ func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)

+ 1 - 2
internal/amneziawgnet/udp_test.go

@@ -6,7 +6,6 @@ import (
 	"testing"
 	"time"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
 	"gvisor.dev/gvisor/pkg/tcpip"
@@ -98,7 +97,7 @@ func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
 	if err != nil {
 		t.Fatalf("client CreateNetTUN: %v", err)
 	}
-	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	clientDev := device.NewDevice(clientTun, newListenBind(""), device.NewLogger(device.LogLevelSilent, ""))
 	defer clientDev.Close()
 
 	clientPrivHex, err := wireguard.KeyToHex(clientPriv)

+ 1 - 0
internal/database/db.go

@@ -87,6 +87,7 @@ func allModels() []any {
 		&model.NodePendingReset{},
 		&model.OutboundSubscription{},
 		&model.SubBalancer{},
+		&model.TuicTrafficReceipt{},
 	}
 }
 

+ 1 - 0
internal/database/migrate_data.go

@@ -59,6 +59,7 @@ func migrationModels() []any {
 		&model.NodePendingReset{},
 		&model.OutboundSubscription{},
 		&model.SubBalancer{},
+		&model.TuicTrafficReceipt{},
 	}
 }
 

+ 7 - 0
internal/database/model/tuic_traffic_receipt.go

@@ -0,0 +1,7 @@
+package model
+
+// TuicTrafficReceipt marks a TUIC traffic journal batch as committed, so a
+// batch replayed after an ambiguous commit is not counted twice.
+type TuicTrafficReceipt struct {
+	ID string `gorm:"primaryKey"`
+}

+ 2 - 3
internal/sub/host_sub.go

@@ -162,14 +162,13 @@ func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
 			}
 		}
 	}
-	// Reality SNI override (host only): JSON realityData reads serverNames and
-	// clash reads serverName, so set both forms.
+	// Reality SNI override (host only): the stream is already in client form, and xray
+	// refuses a reality client carrying the server-side serverNames list (#6690).
 	if isHostEndpoint(ep) {
 		if sec, _ := stream["security"].(string); sec == "reality" {
 			if rs, ok := stream["realitySettings"].(map[string]any); ok && rs != nil {
 				if sni, ok := externalProxySNI(ep); ok {
 					rs["serverName"] = sni
-					rs["serverNames"] = []any{sni}
 				}
 			}
 		}

+ 55 - 0
internal/sub/host_sub_test.go

@@ -480,6 +480,61 @@ func TestSub_HostRealitySniOverride(t *testing.T) {
 	}
 }
 
+// A reality host's SNI reaches JSON and Clash as serverName only: xray refuses a
+// reality client that also carries serverNames (#6690).
+func TestSub_HostRealitySniJSONAndClash(t *testing.T) {
+	seedSubDB(t)
+	realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
+	ib := seedSubInbound(t, "s1", "rlj", 4491, 1, realityStream)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "RLJ", Address: "rl.cdn.com", Port: 8443,
+		Security: "reality", Sni: "host.reality.com",
+	})
+
+	out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	var doc map[string]any
+	if err := json.Unmarshal([]byte(out), &doc); err != nil {
+		t.Fatalf("a single-config subscription should be one JSON object: %v\n%s", err, out)
+	}
+	reality := proxyRealitySettings(t, doc)
+	if got := reality["serverName"]; got != "host.reality.com" {
+		t.Fatalf("json serverName = %v, want the host's SNI host.reality.com", got)
+	}
+	if names, leaked := reality["serverNames"]; leaked {
+		t.Fatalf("server-side serverNames %v leaked into the json reality client:\n%s", names, out)
+	}
+
+	yaml, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(yaml, "servername: host.reality.com") {
+		t.Fatalf("clash proxy should carry the host's SNI:\n%s", yaml)
+	}
+}
+
+func proxyRealitySettings(t *testing.T, doc map[string]any) map[string]any {
+	t.Helper()
+	outbounds, _ := doc["outbounds"].([]any)
+	for _, ob := range outbounds {
+		outbound, _ := ob.(map[string]any)
+		if outbound["tag"] != "proxy" {
+			continue
+		}
+		stream, _ := outbound["streamSettings"].(map[string]any)
+		reality, ok := stream["realitySettings"].(map[string]any)
+		if !ok {
+			t.Fatalf("proxy outbound has no realitySettings: %v", outbound)
+		}
+		return reality
+	}
+	t.Fatalf("no proxy outbound in %v", doc)
+	return nil
+}
+
 // #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
 // absent from GetClash but present in the raw GetSubs output.
 func TestSub_ExcludeFromSubTypes(t *testing.T) {

+ 7 - 3
internal/sub/service.go

@@ -2016,14 +2016,18 @@ func subKey(c model.Client) string {
 	return c.Email
 }
 
-// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a
-// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it.
+// deriveSpiderX maps the seed and a stable client key to a per-client "/path" plus the seed's
+// query, where xray reads its spider settings (#6693); frontend/src/lib/xray/spider-x.ts mirrors it.
 func deriveSpiderX(seed, clientKey string) string {
 	if seed == "" && clientKey == "" {
 		return "/" + random.Seq(15)
 	}
 	sum := sha256.Sum256([]byte(seed + "|" + clientKey))
-	return "/" + hex.EncodeToString(sum[:])[:15]
+	path := "/" + hex.EncodeToString(sum[:])[:15]
+	if _, query, _ := strings.Cut(seed, "?"); query != "" {
+		return path + "?" + query
+	}
+	return path
 }
 
 func buildVmessLink(obj map[string]any) string {

+ 21 - 0
internal/sub/service_sharelink_test.go

@@ -143,6 +143,23 @@ func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
 	}
 }
 
+// A seed's spider settings (p, c, t, i, r) ride along in the share link's spx,
+// escaped so they stay inside that one parameter.
+func TestGenVlessLink_RealitySpiderXKeepsSpiderSettings(t *testing.T) {
+	s := &SubService{}
+	inbound := realityTwoClientInbound()
+	inbound.StreamSettings = strings.Replace(inbound.StreamSettings, `"spiderX":"/seed"`, `"spiderX":"/seed?p=40-400&r=500-2000"`, 1)
+
+	link := s.genVlessLink(inbound, "alice")
+	if got, want := spxParam(t, link), "/09dd00b3f8c01f5?p=40-400&r=500-2000"; got != want {
+		t.Fatalf("spx = %q, want %q", got, want)
+	}
+	u, _ := url.Parse(link)
+	if u.Query().Get("p") != "" || u.Query().Get("r") != "" {
+		t.Fatalf("spider settings leaked out of spx into the link's own query: %q", link)
+	}
+}
+
 func TestDeriveSpiderX(t *testing.T) {
 	if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
 		t.Fatalf("deriveSpiderX not deterministic: %q", got)
@@ -168,6 +185,10 @@ func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
 	vectors := map[string]struct{ seed, clientKey, want string }{
 		"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
 		"seed only":      {"/", "", "/d08ed99bd9afc60"},
+		// xray reads p, c, t, i and r from the spiderX query as the spider's
+		// own settings, so the seed's query must survive the derivation.
+		"seed with spider settings": {"/seed?p=40-400&r=500-2000", "subAlice", "/09dd00b3f8c01f5?p=40-400&r=500-2000"},
+		"spider settings only":      {"/?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000", "", "/ac2cb268d22908e?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000"},
 	}
 	for name, v := range vectors {
 		t.Run(name, func(t *testing.T) {

+ 7 - 1
internal/sub/service_tuic_test.go

@@ -146,7 +146,7 @@ func TestBuildTuicProxy_ExternalProxyOverrides(t *testing.T) {
 		Port:     8443,
 		Protocol: model.TUIC,
 		Remark:   "tuic-base",
-		Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key","sni":"base.example.com","alpn":["h3"]},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
+		Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key","congestion_control":"new_reno","udp_relay_mode":"quic","sni":"base.example.com","alpn":["h3"]},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
 	}
 	client := model.Client{Email: "user@test"}
 
@@ -161,6 +161,12 @@ func TestBuildTuicProxy_ExternalProxyOverrides(t *testing.T) {
 	if !reflect.DeepEqual(baseProxy["alpn"], []string{"h3"}) {
 		t.Fatalf("base alpn = %v, want [h3]", baseProxy["alpn"])
 	}
+	if baseProxy["congestion-controller"] != "new_reno" {
+		t.Fatalf("base congestion controller = %v, want new_reno", baseProxy["congestion-controller"])
+	}
+	if baseProxy["udp-relay-mode"] != "quic" {
+		t.Fatalf("base UDP relay mode = %v, want quic", baseProxy["udp-relay-mode"])
+	}
 	if _, ok := baseProxy["skip-cert-verify"]; ok {
 		t.Fatalf("base skip-cert-verify should not be set")
 	}

+ 220 - 0
internal/tuic/auth.go

@@ -0,0 +1,220 @@
+package tuic
+
+import (
+	"crypto/subtle"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"sync"
+	"sync/atomic"
+
+	"github.com/google/uuid"
+)
+
+var (
+	ErrUserNotFound    = errors.New("tuic: user not found")
+	ErrAuthFailed      = errors.New("tuic: authentication failed")
+	ErrInvalidTLSState = errors.New("tuic: TLS connection state not available")
+)
+
+// User represents a configured TUIC client for authentication and billing.
+type User struct {
+	TrafficID int
+	UUID      [16]byte
+	UUIDStr   string
+	Password  string
+	Email     string
+	Traffic   *UserTraffic
+	sessions  atomic.Int64
+}
+
+type UserTraffic struct {
+	BytesUp   atomic.Int64
+	BytesDown atomic.Int64
+}
+
+// UserRegistry is a thread-safe registry of TUIC users for an inbound.
+type UserRegistry struct {
+	mu      sync.RWMutex
+	users   map[[16]byte]*User
+	retired map[*User]struct{}
+}
+
+// NewUserRegistry creates an empty UserRegistry.
+func NewUserRegistry() *UserRegistry {
+	return &UserRegistry{
+		users:   make(map[[16]byte]*User),
+		retired: make(map[*User]struct{}),
+	}
+}
+
+// SetUsers updates the user list atomically in memory, preserving counters and
+// pointer stability for active sessions. It returns any users removed from the registry.
+func (ur *UserRegistry) SetUsers(clients []TuicClientSettings) (revoked []*User) {
+	ur.mu.Lock()
+	defer ur.mu.Unlock()
+
+	newMap := make(map[[16]byte]*User, len(clients))
+	for _, c := range clients {
+		parsed, err := uuid.Parse(c.UUID)
+		if err != nil {
+			continue
+		}
+		if existing, ok := ur.users[parsed]; ok && existing.Password == c.Password && existing.Email == c.Email && existing.TrafficID == c.TrafficID {
+			newMap[parsed] = existing
+		} else if ok {
+			ur.retired[existing] = struct{}{}
+			revoked = append(revoked, existing)
+			newMap[parsed] = newUser(parsed, c)
+		} else {
+			newMap[parsed] = newUser(parsed, c)
+		}
+	}
+	for id, oldUser := range ur.users {
+		if _, ok := newMap[id]; !ok {
+			revoked = append(revoked, oldUser)
+			ur.retired[oldUser] = struct{}{}
+		}
+	}
+	ur.users = newMap
+	return revoked
+}
+
+func newUser(id [16]byte, c TuicClientSettings) *User {
+	return &User{
+		TrafficID: c.TrafficID, UUID: id, UUIDStr: uuid.UUID(id).String(), Password: c.Password, Email: c.Email,
+		Traffic: &UserTraffic{},
+	}
+}
+
+// AddTestTraffic adds traffic counters to a user by email for testing purposes.
+func (ur *UserRegistry) AddTestTraffic(email string, up, down int64) bool {
+	ur.mu.RLock()
+	defer ur.mu.RUnlock()
+	for _, u := range ur.users {
+		if u.Email == email {
+			u.Traffic.BytesUp.Add(up)
+			u.Traffic.BytesDown.Add(down)
+			return true
+		}
+	}
+	return false
+}
+
+// ClientTrafficDelta represents the traffic delta for a user.
+type ClientTrafficDelta struct {
+	TrafficID int
+	Email     string
+	UUID      string
+	InboundID int
+	Up        int64
+	Down      int64
+}
+
+// CollectTrafficDeltas drains and returns byte deltas for all users since the last call.
+func (ur *UserRegistry) CollectTrafficDeltas() []ClientTrafficDelta {
+	ur.mu.Lock()
+	defer ur.mu.Unlock()
+
+	var deltas []ClientTrafficDelta
+	collect := func(u *User, retired bool) {
+		up := u.Traffic.BytesUp.Swap(0)
+		down := u.Traffic.BytesDown.Swap(0)
+		if up > 0 || down > 0 {
+			deltas = append(deltas, ClientTrafficDelta{
+				TrafficID: u.TrafficID,
+				Email:     u.Email,
+				UUID:      u.UUIDStr,
+				Up:        up,
+				Down:      down,
+			})
+		}
+		if retired && u.sessions.Load() == 0 {
+			delete(ur.retired, u)
+		}
+	}
+	for _, u := range ur.users {
+		collect(u, false)
+	}
+	for u := range ur.retired {
+		collect(u, true)
+	}
+	return deltas
+}
+
+func (ur *UserRegistry) sessionEnded(user *User) {
+	if user != nil {
+		user.sessions.Add(-1)
+	}
+}
+
+// Authenticate verifies the client's token using RFC 5705 Keying Material Exporter.
+// According to TUIC v5 specification:
+// - label: client UUID
+// - context: raw password
+// - length: 32 bytes
+func (ur *UserRegistry) Authenticate(cs *tls.ConnectionState, rawUUID [16]byte, token [32]byte) (*User, error) {
+	return ur.authenticate(cs, rawUUID, token, nil)
+}
+
+// AuthenticateAndRegister holds the registry read lock through connection
+// registration, making successful authentication atomic with user revocation.
+func (ur *UserRegistry) AuthenticateAndRegister(cs *tls.ConnectionState, rawUUID [16]byte, token [32]byte, register func(*User) bool) (*User, error) {
+	return ur.authenticate(cs, rawUUID, token, register)
+}
+
+func (ur *UserRegistry) authenticate(cs *tls.ConnectionState, rawUUID [16]byte, token [32]byte, register func(*User) bool) (*User, error) {
+	if cs == nil {
+		return nil, ErrInvalidTLSState
+	}
+
+	ur.mu.RLock()
+	defer ur.mu.RUnlock()
+	user, exists := ur.users[rawUUID]
+
+	if !exists {
+		return nil, ErrUserNotFound
+	}
+	if !cs.HandshakeComplete {
+		return nil, ErrInvalidTLSState
+	}
+
+	// Try with raw 16-byte UUID as label
+	expectedToken, err := cs.ExportKeyingMaterial(string(rawUUID[:]), []byte(user.Password), 32)
+	if err == nil && subtle.ConstantTimeCompare(token[:], expectedToken) == 1 {
+		if register != nil && !register(user) {
+			return nil, ErrUserNotFound
+		}
+		return user, nil
+	}
+
+	// Fallback to formatted 36-char string representation of UUID as label
+	expectedTokenStr, errStr := cs.ExportKeyingMaterial(user.UUIDStr, []byte(user.Password), 32)
+	if errStr == nil && subtle.ConstantTimeCompare(token[:], expectedTokenStr) == 1 {
+		if register != nil && !register(user) {
+			return nil, ErrUserNotFound
+		}
+		return user, nil
+	}
+
+	if err != nil && errStr != nil {
+		return nil, fmt.Errorf("%w: export keying material: %w", ErrAuthFailed, err)
+	}
+
+	return nil, ErrAuthFailed
+}
+
+func ValidateClients(clients []TuicClientSettings) error {
+	seen := make(map[uuid.UUID]bool, len(clients))
+	for _, client := range clients {
+		id, err := uuid.Parse(client.UUID)
+		if err != nil {
+			return errors.New("tuic: invalid client UUID")
+		}
+		if seen[id] {
+			return errors.New("tuic: duplicate client UUID")
+		}
+		seen[id] = true
+	}
+	return nil
+}

+ 180 - 0
internal/tuic/auth_test.go

@@ -0,0 +1,180 @@
+package tuic
+
+import (
+	"crypto/rand"
+	"crypto/tls"
+	"errors"
+	"net"
+	"sync"
+	"testing"
+
+	"github.com/google/uuid"
+)
+
+func TestUserRegistryBasic(t *testing.T) {
+	reg := NewUserRegistry()
+	testUUID := uuid.New()
+
+	reg.SetUsers([]TuicClientSettings{
+		{
+			UUID:     testUUID.String(),
+			Password: "supersecretpassword",
+			Email:    "[email protected]",
+		},
+	})
+
+	var fakeUUID [16]byte
+	copy(fakeUUID[:], testUUID[:])
+
+	var unknownUUID [16]byte
+	_, _ = rand.Read(unknownUUID[:])
+	_, err := reg.Authenticate(&tls.ConnectionState{}, unknownUUID, [32]byte{})
+	if !errors.Is(err, ErrUserNotFound) {
+		t.Fatalf("expected ErrUserNotFound, got %v", err)
+	}
+}
+
+func TestUserRegistryCredentialUpdatesKeepOldCounters(t *testing.T) {
+	reg := NewUserRegistry()
+	u1 := uuid.New().String()
+	u2 := uuid.New().String()
+
+	reg.SetUsers([]TuicClientSettings{
+		{UUID: u1, Password: "pass1", Email: "[email protected]"},
+		{UUID: u2, Password: "pass2", Email: "[email protected]"},
+	})
+
+	reg.AddTestTraffic("[email protected]", 100, 200)
+
+	parsedU1, _ := uuid.Parse(u1)
+	user1Before := reg.users[parsedU1]
+	if user1Before == nil {
+		t.Fatalf("expected user1 in registry")
+	}
+
+	revoked := reg.SetUsers([]TuicClientSettings{
+		{UUID: u1, Password: "newpassword", Email: "[email protected]"},
+		{UUID: u2, Password: "pass2", Email: "[email protected]"},
+	})
+
+	if len(revoked) != 1 || revoked[0] != user1Before {
+		t.Fatalf("expected changed user snapshot to be retired, got %+v", revoked)
+	}
+
+	user1After := reg.users[parsedU1]
+	if user1Before == user1After {
+		t.Fatal("expected immutable user snapshot to be replaced")
+	}
+	if user1After.Password != "newpassword" || user1After.Email != "[email protected]" {
+		t.Fatalf("expected updated password and email, got %s, %s", user1After.Password, user1After.Email)
+	}
+
+	deltas := reg.CollectTrafficDeltas()
+	if len(deltas) != 1 || deltas[0].Email != "[email protected]" || deltas[0].Up != 100 || deltas[0].Down != 200 {
+		t.Fatalf("expected preserved traffic deltas, got %+v", deltas)
+	}
+}
+
+func TestUserRegistryRevocation(t *testing.T) {
+	reg := NewUserRegistry()
+	u1 := uuid.New().String()
+	u2 := uuid.New().String()
+
+	reg.SetUsers([]TuicClientSettings{
+		{UUID: u1, Password: "pass1", Email: "[email protected]"},
+		{UUID: u2, Password: "pass2", Email: "[email protected]"},
+	})
+
+	// Remove u1, keep only u2
+	revoked := reg.SetUsers([]TuicClientSettings{
+		{UUID: u2, Password: "pass2", Email: "[email protected]"},
+	})
+
+	if len(revoked) != 1 || revoked[0].Email != "[email protected]" {
+		t.Fatalf("expected u1 revoked, got %+v", revoked)
+	}
+
+	parsedU1, _ := uuid.Parse(u1)
+	if _, exists := reg.users[parsedU1]; exists {
+		t.Fatalf("expected u1 removed from registry")
+	}
+}
+
+func TestUserRegistryRetainsRevokedTrafficUntilSessionsFinish(t *testing.T) {
+	reg := NewUserRegistry()
+	uuidStr := uuid.New().String()
+	reg.SetUsers([]TuicClientSettings{{UUID: uuidStr, Password: "p", Email: "[email protected]"}})
+	parsed, _ := uuid.Parse(uuidStr)
+	user := reg.users[parsed]
+	user.sessions.Store(1)
+	user.Traffic.BytesUp.Store(11)
+	user.Traffic.BytesDown.Store(22)
+	reg.SetUsers(nil)
+
+	if got := reg.CollectTrafficDeltas(); len(got) != 1 || got[0].Email != user.Email || got[0].Up != 11 || got[0].Down != 22 {
+		t.Fatalf("revoked traffic delta = %+v", got)
+	}
+	user.Traffic.BytesUp.Add(3)
+	if got := reg.CollectTrafficDeltas(); len(got) != 1 || got[0].Up != 3 {
+		t.Fatalf("final active-session delta = %+v", got)
+	}
+	reg.sessionEnded(user)
+	if got := reg.CollectTrafficDeltas(); len(got) != 0 {
+		t.Fatalf("empty retired user produced another delta: %+v", got)
+	}
+	if len(reg.retired) != 0 {
+		t.Fatalf("finished user remained retired: %+v", reg.retired)
+	}
+}
+
+func TestUserRegistryConcurrentCredentialUpdatesAndAuthentication(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+	certificate, err := tls.X509KeyPair(certPEM, keyPEM)
+	if err != nil {
+		t.Fatalf("tls.X509KeyPair: %v", err)
+	}
+	clientRaw, serverRaw := net.Pipe()
+	clientConn := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13})
+	serverConn := tls.Server(serverRaw, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS13})
+	serverHandshake := make(chan error, 1)
+	go func() { serverHandshake <- serverConn.Handshake() }()
+	if err := clientConn.Handshake(); err != nil {
+		t.Fatalf("client TLS handshake: %v", err)
+	}
+	if err := <-serverHandshake; err != nil {
+		t.Fatalf("server TLS handshake: %v", err)
+	}
+	t.Cleanup(func() {
+		_ = clientConn.Close()
+		_ = serverConn.Close()
+	})
+	state := clientConn.ConnectionState()
+	if !state.HandshakeComplete {
+		t.Fatal("TLS handshake did not complete")
+	}
+
+	reg := NewUserRegistry()
+	uuidStr := uuid.New().String()
+	parsed, _ := uuid.Parse(uuidStr)
+	reg.SetUsers([]TuicClientSettings{{UUID: uuidStr, Password: "initial", Email: "[email protected]"}})
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		for i := range 1000 {
+			password := "a"
+			if i%2 == 0 {
+				password = "b"
+			}
+			reg.SetUsers([]TuicClientSettings{{UUID: uuidStr, Password: password, Email: "[email protected]"}})
+		}
+	}()
+	go func() {
+		defer wg.Done()
+		for range 1000 {
+			_, _ = reg.Authenticate(&state, parsed, [32]byte{})
+		}
+	}()
+	wg.Wait()
+}

+ 0 - 94
internal/tuic/config.go

@@ -1,94 +0,0 @@
-package tuic
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/config"
-)
-
-type ServerConfig struct {
-	Server                string            `json:"server"`
-	Users                 map[string]string `json:"users"`
-	Certificate           string            `json:"certificate"`
-	PrivateKey            string            `json:"private_key"`
-	CongestionControl     string            `json:"congestion_control"`
-	ALPN                  []string          `json:"alpn"`
-	ZeroRTTHandshake      bool              `json:"zero_rtt_handshake"`
-	LogLevel              string            `json:"log_level"`
-	MaxIdleTime           string            `json:"max_idle_time,omitempty"`
-	AuthTimeout           string            `json:"auth_timeout,omitempty"`
-	MaxExternalPacketSize int               `json:"max_external_packet_size,omitempty"`
-}
-
-// bind is where the sidecar itself listens: a loopback port behind the
-// panel's relay, never the inbound's public address (see udpRelay).
-func GenerateConfig(inst Instance, bind string) ([]byte, error) {
-	users := make(map[string]string, len(inst.Clients))
-	for _, c := range inst.Clients {
-		if c.UUID != "" && c.Password != "" {
-			users[c.UUID] = c.Password
-		}
-	}
-
-	authTimeoutStr := ""
-	if inst.AuthenticationTimeout > 0 {
-		authTimeoutStr = fmt.Sprintf("%ds", inst.AuthenticationTimeout)
-	}
-
-	maxIdleStr := ""
-	if inst.MaxIdleTime > 0 {
-		maxIdleStr = fmt.Sprintf("%ds", inst.MaxIdleTime)
-	}
-
-	logLevel := inst.LogLevel
-	if logLevel == "" {
-		logLevel = "info"
-	}
-
-	cfg := ServerConfig{
-		Server:                bind,
-		Users:                 users,
-		Certificate:           inst.Certificate,
-		PrivateKey:            inst.PrivateKey,
-		CongestionControl:     inst.CongestionControl,
-		ALPN:                  inst.ALPN,
-		ZeroRTTHandshake:      inst.ZeroRTTHandshake,
-		LogLevel:              logLevel,
-		AuthTimeout:           authTimeoutStr,
-		MaxIdleTime:           maxIdleStr,
-		MaxExternalPacketSize: inst.MaxUdpRelayPacketSize,
-	}
-
-	return json.MarshalIndent(cfg, "", "  ")
-}
-
-func ConfigDir() string {
-	return filepath.Join(config.GetBinFolderPath(), "tuic")
-}
-
-func ConfigPathForID(id int) string {
-	return filepath.Join(ConfigDir(), fmt.Sprintf("tuic_%d.json", id))
-}
-
-func WriteConfigFile(id int, data []byte) (string, error) {
-	dir := ConfigDir()
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		return "", err
-	}
-	path := ConfigPathForID(id)
-	if err := os.WriteFile(path, data, 0o600); err != nil {
-		return "", err
-	}
-	return path, nil
-}
-
-func RemoveConfigFile(id int) error {
-	path := ConfigPathForID(id)
-	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
-		return err
-	}
-	return nil
-}

+ 0 - 87
internal/tuic/config_test.go

@@ -1,87 +0,0 @@
-package tuic
-
-import (
-	"encoding/json"
-	"testing"
-)
-
-func TestGenerateConfig(t *testing.T) {
-	inst := Instance{
-		Id:                    1,
-		Port:                  8443,
-		Listen:                "0.0.0.0",
-		Certificate:           "/etc/ssl/cert.pem",
-		PrivateKey:            "/etc/ssl/key.pem",
-		CongestionControl:     "bbr",
-		ALPN:                  []string{"h3", "spdy/3.1"},
-		UDPRelayMode:          "native",
-		ZeroRTTHandshake:      true,
-		LogLevel:              "info",
-		MaxIdleTime:           15,
-		AuthenticationTimeout: 3,
-		MaxUdpRelayPacketSize: 1500,
-		Clients: []TuicClientSettings{
-			{UUID: "uuid-1", Password: "pass-1", Email: "e1"},
-			{UUID: "uuid-2", Password: "pass-2", Email: "e2"},
-		},
-	}
-
-	data, err := GenerateConfig(inst, "127.0.0.1:4433")
-	if err != nil {
-		t.Fatalf("GenerateConfig error: %v", err)
-	}
-
-	var parsed map[string]any
-	if err := json.Unmarshal(data, &parsed); err != nil {
-		t.Fatalf("Unmarshal error: %v", err)
-	}
-
-	if parsed["server"] != "127.0.0.1:4433" {
-		t.Fatalf("expected the sidecar bound to the relay's loopback port, got %v", parsed["server"])
-	}
-	if parsed["certificate"] != "/etc/ssl/cert.pem" || parsed["private_key"] != "/etc/ssl/key.pem" {
-		t.Fatalf("unexpected cert/key in json: %v", parsed)
-	}
-
-	users, ok := parsed["users"].(map[string]any)
-	if !ok {
-		t.Fatalf("expected users map, got %T", parsed["users"])
-	}
-	if users["uuid-1"] != "pass-1" || users["uuid-2"] != "pass-2" {
-		t.Fatalf("unexpected users in json: %v", users)
-	}
-}
-
-func TestGenerateConfigLogLevel(t *testing.T) {
-	tests := []struct {
-		input    string
-		expected string
-	}{
-		{"info", "info"},
-		{"warn", "warn"},
-		{"error", "error"},
-		{"", "info"},
-		{"debug", "debug"},
-		{"trace", "trace"},
-	}
-
-	for _, tc := range tests {
-		inst := Instance{
-			Id:       1,
-			Port:     8443,
-			Listen:   "0.0.0.0",
-			LogLevel: tc.input,
-		}
-		data, err := GenerateConfig(inst, "127.0.0.1:4433")
-		if err != nil {
-			t.Fatalf("GenerateConfig error for %s: %v", tc.input, err)
-		}
-		var parsed map[string]any
-		if err := json.Unmarshal(data, &parsed); err != nil {
-			t.Fatalf("Unmarshal error for %s: %v", tc.input, err)
-		}
-		if parsed["log_level"] != tc.expected {
-			t.Fatalf("expected log_level %s for input %s, got %v", tc.expected, tc.input, parsed["log_level"])
-		}
-	}
-}

+ 112 - 81
internal/tuic/manager.go

@@ -2,8 +2,6 @@ package tuic
 
 import (
 	"fmt"
-	"net"
-	"strconv"
 	"sync"
 	"time"
 
@@ -11,18 +9,17 @@ import (
 )
 
 type managed struct {
-	proc         *Process
-	relay        *udpRelay
+	server       *Server
 	tag          string
-	configPath   string
 	structuralFP string
 	usersFP      string
 }
 
 type Manager struct {
-	mu           sync.Mutex
-	procs        map[int]*managed
-	lastStartErr map[int]string
+	mu             sync.Mutex
+	servers        map[int]*managed
+	lastStartErr   map[int]string
+	pendingTraffic map[string]ClientTrafficDelta
 }
 
 var (
@@ -33,11 +30,9 @@ var (
 func GetManager() *Manager {
 	managerOnce.Do(func() {
 		managerInstance = &Manager{
-			procs:        make(map[int]*managed),
-			lastStartErr: make(map[int]string),
-		}
-		if n := killStrayTuicProcesses(GetBinaryPath()); n > 0 {
-			logger.Warningf("tuic: terminated %d orphaned tuic-server process(es) from a previous run", n)
+			servers:        make(map[int]*managed),
+			lastStartErr:   make(map[int]string),
+			pendingTraffic: make(map[string]ClientTrafficDelta),
 		}
 	})
 	return managerInstance
@@ -46,8 +41,8 @@ func GetManager() *Manager {
 func (m *Manager) HasRunning() bool {
 	m.mu.Lock()
 	defer m.mu.Unlock()
-	for _, mg := range m.procs {
-		if mg.proc != nil && mg.proc.IsRunning() {
+	for _, mg := range m.servers {
+		if mg.server != nil && mg.server.IsRunning() {
 			return true
 		}
 	}
@@ -61,6 +56,9 @@ func (m *Manager) Ensure(inst Instance) error {
 }
 
 func (m *Manager) ensureLocked(inst Instance) error {
+	if err := ValidateClients(inst.Clients); err != nil {
+		return err
+	}
 	if len(inst.Clients) == 0 {
 		m.removeLocked(inst.Id)
 		return nil
@@ -69,78 +67,91 @@ func (m *Manager) ensureLocked(inst Instance) error {
 	structuralFP := inst.StructuralFingerprint()
 	usersFP := inst.UsersFingerprint()
 
-	uuidToEmail := make(map[string]string, len(inst.Clients))
-	for _, c := range inst.Clients {
-		if c.UUID != "" && c.Email != "" {
-			uuidToEmail[c.UUID] = c.Email
-		}
-	}
-
-	if existing, ok := m.procs[inst.Id]; ok && existing != nil {
-		if existing.proc != nil && existing.proc.IsRunning() &&
-			existing.structuralFP == structuralFP && existing.usersFP == usersFP {
+	if existing, ok := m.servers[inst.Id]; ok && existing != nil {
+		if existing.server != nil && existing.server.IsRunning() && existing.structuralFP == structuralFP {
 			existing.tag = inst.Tag
-			existing.proc.UpdateClients(uuidToEmail)
+			existing.server.UpdateRuntimeSettings(inst.Tag, inst.CongestionControl, inst.LogLevel)
+			if existing.usersFP != usersFP {
+				existing.usersFP = usersFP
+				existing.server.UpdateUsers(inst.Clients)
+			}
 			return nil
 		}
-		stopManaged(existing)
-		delete(m.procs, inst.Id)
+		m.stopAndDrainLocked(existing)
+		delete(m.servers, inst.Id)
 	}
 
-	proc, relay, configPath, err := m.startLocked(inst, uuidToEmail)
+	server, err := m.startLocked(inst)
 	if err != nil {
 		if m.lastStartErr[inst.Id] != err.Error() {
 			m.lastStartErr[inst.Id] = err.Error()
-			logger.Warningf("tuic: failed to start tuic-server for inbound %d (%s): %v", inst.Id, inst.Tag, err)
+			if tuicLogWarn >= parseLogLevel(inst.LogLevel) {
+				logger.Warningf("tuic: inbound %d (%s): failed to start server: %v", inst.Id, inst.Tag, err)
+			}
 		}
 		return err
 	}
 	delete(m.lastStartErr, inst.Id)
 
-	m.procs[inst.Id] = &managed{
-		proc:         proc,
-		relay:        relay,
+	m.servers[inst.Id] = &managed{
+		server:       server,
 		tag:          inst.Tag,
-		configPath:   configPath,
 		structuralFP: structuralFP,
 		usersFP:      usersFP,
 	}
 	return nil
 }
 
-func (m *Manager) startLocked(inst Instance, uuidToEmail map[string]string) (*Process, *udpRelay, string, error) {
-	port, err := freeLoopbackUDPPort()
-	if err != nil {
-		return nil, nil, "", fmt.Errorf("tuic: pick sidecar port for %d: %w", inst.Id, err)
-	}
-	upstream := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
-	configBytes, err := GenerateConfig(inst, net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
-	if err != nil {
-		return nil, nil, "", fmt.Errorf("tuic: generate config for %d: %w", inst.Id, err)
+func (m *Manager) startLocked(inst Instance) (*Server, error) {
+	relay := &SocksRelay{
+		Addr:     fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
+		Password: SocksPassword(),
 	}
-	configPath, err := WriteConfigFile(inst.Id, configBytes)
+	server, err := NewServer(inst, relay)
 	if err != nil {
-		return nil, nil, "", fmt.Errorf("tuic: write config for %d: %w", inst.Id, err)
+		return nil, fmt.Errorf("tuic: init server for %d: %w", inst.Id, err)
 	}
-	relay, err := startUDPRelay(inst.BindTo(), upstream, relayFlowIdle)
-	if err != nil {
-		_ = RemoveConfigFile(inst.Id)
-		return nil, nil, "", fmt.Errorf("tuic: listen on %s for %d: %w", inst.BindTo(), inst.Id, err)
+	if err := server.Start(); err != nil {
+		return nil, fmt.Errorf("tuic: start server on %s for %d: %w", inst.BindTo(), inst.Id, err)
 	}
-	proc := newProcess(configPath, inst.Tag, uuidToEmail)
-	if err := proc.Start(); err != nil {
-		relay.Close()
-		_ = RemoveConfigFile(inst.Id)
-		return nil, nil, "", err
+	return server, nil
+}
+
+func (m *Manager) stopAndDrainLocked(mg *managed) {
+	if mg == nil || mg.server == nil {
+		return
 	}
-	return proc, relay, configPath, nil
+	_ = mg.server.Close()
+	m.appendPendingTrafficLocked(mg.server.CollectClientTraffic())
 }
 
-func stopManaged(mg *managed) {
-	if mg.proc != nil && mg.proc.IsRunning() {
-		_ = mg.proc.Stop()
+func (m *Manager) appendPendingTrafficLocked(deltas []ClientTrafficDelta) {
+	if m.pendingTraffic == nil {
+		m.pendingTraffic = make(map[string]ClientTrafficDelta)
+	}
+	for _, delta := range deltas {
+		key := delta.Email
+		if delta.TrafficID > 0 {
+			key = fmt.Sprintf("traffic:%d", delta.TrafficID)
+		}
+		if delta.TrafficID == 0 && delta.InboundID > 0 && delta.UUID != "" {
+			key = fmt.Sprintf("%d:%s", delta.InboundID, delta.UUID)
+		}
+		current := m.pendingTraffic[key]
+		current.Email = delta.Email
+		current.UUID = delta.UUID
+		current.InboundID = delta.InboundID
+		current.TrafficID = delta.TrafficID
+		current.Up += delta.Up
+		current.Down += delta.Down
+		m.pendingTraffic[key] = current
 	}
-	mg.relay.Close()
+}
+
+func (m *Manager) RequeueClientTraffic(deltas []ClientTrafficDelta) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.appendPendingTrafficLocked(deltas)
 }
 
 func (m *Manager) GetActiveClients(window time.Duration) ([]string, []string) {
@@ -148,9 +159,9 @@ func (m *Manager) GetActiveClients(window time.Duration) ([]string, []string) {
 	defer m.mu.Unlock()
 	var emails []string
 	var tags []string
-	for _, mg := range m.procs {
-		if mg.proc != nil && mg.proc.IsRunning() {
-			active := mg.proc.GetActiveEmails(window)
+	for _, mg := range m.servers {
+		if mg.server != nil && mg.server.IsRunning() {
+			active := mg.server.GetActiveEmails(window)
 			if len(active) > 0 {
 				emails = append(emails, active...)
 				tags = append(tags, mg.tag)
@@ -166,23 +177,45 @@ type InboundTrafficDelta struct {
 	Down int64
 }
 
-func (m *Manager) CollectTraffic() []InboundTrafficDelta {
+func (m *Manager) CollectClientTraffic() []ClientTrafficDelta {
+	_, clients := m.CollectAllTraffic()
+	return clients
+}
+
+func (m *Manager) CollectAllTraffic() ([]InboundTrafficDelta, []ClientTrafficDelta) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
-	var out []InboundTrafficDelta
-	for _, mg := range m.procs {
-		if mg.relay != nil && mg.proc != nil && mg.proc.IsRunning() {
-			deltaUp, deltaDown := mg.relay.CollectTraffic()
-			if deltaUp > 0 || deltaDown > 0 {
-				out = append(out, InboundTrafficDelta{
+
+	var inbounds []InboundTrafficDelta
+	clients := make([]ClientTrafficDelta, 0, len(m.pendingTraffic))
+	for email, delta := range m.pendingTraffic {
+		clients = append(clients, delta)
+		delete(m.pendingTraffic, email)
+	}
+
+	for _, mg := range m.servers {
+		if mg.server != nil && mg.server.IsRunning() {
+			up, down, cDeltas := mg.server.CollectAllTraffic()
+			if up > 0 || down > 0 {
+				inbounds = append(inbounds, InboundTrafficDelta{
 					Tag:  mg.tag,
-					Up:   deltaUp,
-					Down: deltaDown,
+					Up:   up,
+					Down: down,
 				})
 			}
+			clients = append(clients, cDeltas...)
 		}
 	}
-	return out
+	return inbounds, clients
+}
+
+func (m *Manager) AddTestTraffic(id int, email string, up, down int64) bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if mg, ok := m.servers[id]; ok && mg.server != nil {
+		return mg.server.AddTestTraffic(email, up, down)
+	}
+	return false
 }
 
 func (m *Manager) Remove(id int) {
@@ -192,10 +225,9 @@ func (m *Manager) Remove(id int) {
 }
 
 func (m *Manager) removeLocked(id int) {
-	if existing, ok := m.procs[id]; ok && existing != nil {
-		stopManaged(existing)
-		_ = RemoveConfigFile(id)
-		delete(m.procs, id)
+	if existing, ok := m.servers[id]; ok && existing != nil {
+		m.stopAndDrainLocked(existing)
+		delete(m.servers, id)
 		delete(m.lastStartErr, id)
 	}
 }
@@ -209,7 +241,7 @@ func (m *Manager) Reconcile(desired []Instance) {
 		desiredMap[inst.Id] = inst
 	}
 
-	for id := range m.procs {
+	for id := range m.servers {
 		if _, ok := desiredMap[id]; !ok {
 			m.removeLocked(id)
 		}
@@ -223,9 +255,8 @@ func (m *Manager) Reconcile(desired []Instance) {
 func (m *Manager) StopAll() {
 	m.mu.Lock()
 	defer m.mu.Unlock()
-	for id, mg := range m.procs {
-		stopManaged(mg)
-		_ = RemoveConfigFile(id)
+	for _, mg := range m.servers {
+		m.stopAndDrainLocked(mg)
 	}
-	m.procs = make(map[int]*managed)
+	m.servers = make(map[int]*managed)
 }

+ 248 - 0
internal/tuic/manager_live_traffic_test.go

@@ -0,0 +1,248 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"fmt"
+	"io"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/apernet/quic-go"
+	"github.com/google/uuid"
+)
+
+type reauditCCSnapshot struct {
+	conn   *quic.Conn
+	chosen string
+	actual string
+	sender uintptr
+}
+
+func reauditActualSender(conn *quic.Conn) (string, uintptr) {
+	cc, unlock := lockedCongestion(conn)
+	defer unlock()
+	ptr := cc.Pointer()
+	if cc.Type().String() == "*ackhandler.ccAdapterEx" || cc.Type().String() == "*ackhandler.ccAdapter" {
+		sender := cc.Elem().FieldByName("CC").Elem()
+		return sender.Type().String(), ptr
+	}
+	return fmt.Sprintf("%s reno=%t", cc.Type(), cc.Elem().FieldByName("reno").Bool()), ptr
+}
+
+func reauditWantedSender(controller string) string {
+	if controller == "bbr" {
+		return "*bbr.bbrSender"
+	}
+	return "*congestion.cubicSender reno=true"
+}
+
+func TestAudit3ManagerEnsureActualSendersWithPersistentTraffic(t *testing.T) {
+	cert, key := generateTestCert(t)
+	_, cleanup := audit3StartSocksForManager(t, "[email protected]", SocksPassword(), 99115)
+	defer cleanup()
+	userID := uuid.MustParse("a0000000-0000-0000-0000-000000000015")
+	inst := Instance{Id: 99115, Tag: "reaudit-cc", Listen: "127.0.0.1", Certificate: string(cert), PrivateKey: string(key), CongestionControl: "new_reno", AuthenticationTimeout: 3, MaxIdleTime: 30, Clients: []TuicClientSettings{{UUID: userID.String(), Password: "secret-reaudit", Email: "[email protected]"}}}
+	manager := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
+	if err := manager.Ensure(inst); err != nil {
+		t.Fatal(err)
+	}
+	defer manager.StopAll()
+	server := manager.servers[inst.Id].server
+	listener := server.quicListener
+	address := server.packetConn.LocalAddr().String()
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	type peer struct {
+		client   *quic.Conn
+		tcp      *quic.Stream
+		snapshot reauditCCSnapshot
+		packetID uint16
+	}
+	var peers []*peer
+	tcpEcho := func(p *peer, message []byte) {
+		t.Helper()
+		_ = p.tcp.SetDeadline(time.Now().Add(2 * time.Second))
+		if _, err := p.tcp.Write(message); err != nil {
+			t.Fatal(err)
+		}
+		reply := make([]byte, len(message))
+		if _, err := io.ReadFull(p.tcp, reply); err != nil {
+			t.Fatal(err)
+		}
+		if !bytes.Equal(reply, message) {
+			t.Fatalf("TCP echo mismatch: %q", reply)
+		}
+	}
+	udpEcho := func(p *peer, streamMode bool, message []byte) {
+		t.Helper()
+		p.packetID++
+		assoc := uint16(100)
+		if streamMode {
+			assoc = 200
+		}
+		var frame bytes.Buffer
+		if err := WritePacket(&frame, assoc, p.packetID, 1, 0, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}, message); err != nil {
+			t.Fatal(err)
+		}
+		var reader io.Reader
+		if streamMode {
+			stream, err := p.client.OpenUniStreamSync(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if _, err := stream.Write(frame.Bytes()); err != nil {
+				t.Fatal(err)
+			}
+			if err := stream.Close(); err != nil {
+				t.Fatal(err)
+			}
+			response, err := p.client.AcceptUniStream(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			reader = response
+		} else {
+			if err := p.client.SendDatagram(frame.Bytes()); err != nil {
+				t.Fatal(err)
+			}
+			response, err := p.client.ReceiveDatagram(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			reader = bytes.NewReader(response)
+		}
+		_, command, err := ReadCommand(reader)
+		if err != nil || command != CmdPacket {
+			t.Fatalf("UDP response command=%d error=%v", command, err)
+		}
+		hdr, err := ReadPacketHeader(reader)
+		if err != nil {
+			t.Fatal(err)
+		}
+		payload, err := readPacketPayload(reader, hdr)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if hdr.AssocID != assoc || !bytes.Equal(payload, message) {
+			t.Fatalf("UDP echo mismatch association=%d payload=%q", hdr.AssocID, payload)
+		}
+	}
+	for step, controller := range []string{"new_reno", "reno", "bbr", "BBR", "cubic", "CuBiC", "", "invalid"} {
+		inst.CongestionControl = controller
+		if err := manager.Ensure(inst); err != nil {
+			t.Fatal(err)
+		}
+		normalized, _ := normalizeCongestionControl(controller)
+		served := normalized
+		if served == "cubic" {
+			served = "new_reno"
+		}
+		if server.quicListener != listener || server.packetConn.LocalAddr().String() != address {
+			t.Fatal("listener changed")
+		}
+		client, err := quic.DialAddr(ctx, address, &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &quic.Config{EnableDatagrams: true, MaxIdleTimeout: 30 * time.Second})
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer client.CloseWithError(0, "")
+		tlsState := client.ConnectionState().TLS
+		token, err := tlsState.ExportKeyingMaterial(string(userID[:]), []byte("secret-reaudit"), 32)
+		if err != nil {
+			t.Fatal(err)
+		}
+		auth, err := client.OpenUniStreamSync(ctx)
+		if err != nil {
+			t.Fatal(err)
+		}
+		authBytes := make([]byte, 50)
+		authBytes[0], authBytes[1] = ProtocolVersion, CmdAuthenticate
+		copy(authBytes[2:18], userID[:])
+		copy(authBytes[18:], token)
+		if _, err := auth.Write(authBytes); err != nil {
+			t.Fatal(err)
+		}
+		if err := auth.Close(); err != nil {
+			t.Fatal(err)
+		}
+
+		waitForClientCongestionSender(t, server, client, served)
+		var serverConn *quic.Conn
+		server.connectionsMu.Lock()
+		for candidate := range server.connections {
+			if matchesClientSocket(candidate, client) {
+				serverConn = candidate
+				break
+			}
+		}
+		server.connectionsMu.Unlock()
+		if serverConn == nil {
+			t.Fatal("server connection missing")
+		}
+		actual, sender := reauditActualSender(serverConn)
+		snap := reauditCCSnapshot{conn: serverConn, chosen: normalized, actual: actual, sender: sender}
+		if actual != reauditWantedSender(normalized) {
+			t.Fatalf("wrong sender: %s", actual)
+		}
+		tcp, err := client.OpenStreamSync(ctx)
+		if err != nil {
+			t.Fatal(err)
+		}
+		var connect bytes.Buffer
+		connect.Write([]byte{ProtocolVersion, CmdConnect})
+		if err := WriteAddress(&connect, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 80}); err != nil {
+			t.Fatal(err)
+		}
+		if _, err := tcp.Write(connect.Bytes()); err != nil {
+			t.Fatal(err)
+		}
+		for _, p := range peers {
+			if p.snapshot.sender == snap.sender {
+				t.Fatal("sender reused across connections")
+			}
+		}
+		peers = append(peers, &peer{client: client, tcp: tcp, snapshot: snap})
+		for i, p := range peers {
+			actual, ptr := reauditActualSender(p.snapshot.conn)
+			if actual != p.snapshot.actual || ptr != p.snapshot.sender {
+				t.Fatalf("existing connection sender changed: %s -> %s", p.snapshot.actual, actual)
+			}
+			msg := fmt.Appendf(nil, "live-step-%d-peer-%d", step, i)
+			tcpEcho(p, msg)
+			udpEcho(p, false, msg)
+			udpEcho(p, true, msg)
+		}
+		t.Logf("step=%d new=%s old peers=%d usable TCP/native UDP/stream UDP; listener preserved", step, snap.actual, len(peers)-1)
+	}
+}
+
+func audit3StartSocksForManager(t *testing.T, expectedUser, expectedPass string, inboundID int) (string, func()) {
+	ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inboundID)))
+	if err != nil {
+		t.Fatalf("failed to listen: %v", err)
+	}
+
+	stop := make(chan struct{})
+
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				select {
+				case <-stop:
+					return
+				default:
+					return
+				}
+			}
+			go handleMockSocksConn(conn, expectedUser, expectedPass)
+		}
+	}()
+
+	return ln.Addr().String(), func() {
+		close(stop)
+		_ = ln.Close()
+	}
+}

+ 140 - 0
internal/tuic/manager_shutdown_test.go

@@ -0,0 +1,140 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"fmt"
+	"io"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	clientquic "github.com/quic-go/quic-go"
+)
+
+func TestAudit3ManagerStopMustInterruptIdleTCPRelay(t *testing.T) {
+	var listener net.Listener
+	var err error
+	var inboundID int
+	for p := 64051; p < 64080; p++ {
+		listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
+		if err == nil {
+			inboundID = 500000 + (p - 64000)
+			break
+		}
+	}
+	if listener == nil {
+		t.Fatal(err)
+	}
+	defer listener.Close()
+	release := make(chan struct{})
+	defer close(release)
+	peerFIN := make(chan struct{})
+	ready := make(chan struct{})
+	go func() {
+		c, err := listener.Accept()
+		if err != nil {
+			return
+		}
+		defer c.Close()
+		var greeting [4]byte
+		if _, err := io.ReadFull(c, greeting[:]); err != nil {
+			return
+		}
+		c.Write([]byte{5, 0})
+		var req [10]byte
+		if _, err := io.ReadFull(c, req[:]); err != nil {
+			return
+		}
+		c.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
+		var payload [1]byte
+		if _, err := io.ReadFull(c, payload[:]); err != nil {
+			return
+		}
+		c.Write(payload[:])
+		close(ready)
+		io.Copy(io.Discard, c)
+		close(peerFIN)
+		<-release
+	}()
+	cert, key := generateTestCert(t)
+	clientID := uuid.New()
+	password := "audit3-password"
+	m := &Manager{servers: make(map[int]*managed), lastStartErr: make(map[int]string), pendingTraffic: make(map[string]ClientTrafficDelta)}
+	if err := m.Ensure(Instance{Id: inboundID, Tag: "audit3-manager-shutdown", Listen: "127.0.0.1", Port: 0, Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"}, AuthenticationTimeout: 2, MaxIdleTime: 30, Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: "close-idle@audit3"}}}); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(m.StopAll)
+	server := m.servers[inboundID].server
+	dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer dialCancel()
+	conn, err := clientquic.DialAddr(dialCtx, server.packetConn.LocalAddr().String(), &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &clientquic.Config{EnableDatagrams: true})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { conn.CloseWithError(0, "") })
+	tlsState := conn.ConnectionState().TLS
+	token, err := tlsState.ExportKeyingMaterial(string(clientID[:]), []byte(password), 32)
+	if err != nil {
+		t.Fatal(err)
+	}
+	auth, err := conn.OpenUniStreamSync(dialCtx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	payload := append([]byte{ProtocolVersion, CmdAuthenticate}, clientID[:]...)
+	payload = append(payload, token...)
+	if _, err := auth.Write(payload); err != nil {
+		t.Fatal(err)
+	}
+	auth.Close()
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	stream, err := conn.OpenStreamSync(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var cmd bytes.Buffer
+	cmd.Write([]byte{ProtocolVersion, CmdConnect})
+	WriteAddress(&cmd, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 443})
+	cmd.WriteByte('x')
+	if _, err := stream.Write(cmd.Bytes()); err != nil {
+		t.Fatal(err)
+	}
+	var echo [1]byte
+	if _, err := io.ReadFull(stream, echo[:]); err != nil {
+		t.Fatal(err)
+	}
+	<-ready
+	closed := make(chan error, 1)
+	started := time.Now()
+	go func() { m.StopAll(); closed <- nil }()
+
+	queried := make(chan bool, 1)
+	go func() { queried <- m.HasRunning() }()
+	select {
+	case <-closed:
+	case <-time.After(time.Second):
+		t.Fatalf("StopAll blocked on idle TCP peer after %s", time.Since(started))
+	}
+	select {
+	case <-queried:
+	case <-time.After(time.Second):
+		t.Fatal("manager query blocked after StopAll")
+	}
+	select {
+	case <-peerFIN:
+	case <-time.After(time.Second):
+		t.Fatal("upstream connection remained open")
+	}
+	_, deltas := m.CollectAllTraffic()
+	if len(deltas) != 1 || deltas[0].Up != 1 || deltas[0].Down != 1 {
+		t.Fatalf("final counters: %+v", deltas)
+	}
+	_, again := m.CollectAllTraffic()
+	if len(again) != 0 {
+		t.Fatalf("repeated final counters: %+v", again)
+	}
+}

+ 352 - 48
internal/tuic/manager_test.go

@@ -1,95 +1,399 @@
 package tuic
 
 import (
-	"encoding/json"
+	"context"
+	"crypto/tls"
+	"fmt"
 	"net"
-	"os"
-	"path/filepath"
-	"runtime"
+	"reflect"
+	"strings"
+	"sync"
 	"testing"
+	"time"
+	"unsafe"
+
+	"github.com/apernet/quic-go"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
-func TestEnsureFrontsSidecarWithRelayAndRemoveReleasesPort(t *testing.T) {
-	if runtime.GOOS == "windows" {
-		t.Skip("uses a shell script as the sidecar binary")
-	}
-	bin := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", bin)
-	if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
-		t.Fatal(err)
-	}
-	port, err := freeLoopbackUDPPort()
+func TestEnsureStartsServerAndReconciles(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+
+	// Find free UDP port
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
 	if err != nil {
 		t.Fatal(err)
 	}
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	_ = pc.Close()
+
 	inst := Instance{
-		Id: 7, Tag: "tuic-7", Listen: "127.0.0.1", Port: port,
-		Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
+		Id:          11,
+		Tag:         "tuic-11",
+		Listen:      "127.0.0.1",
+		Port:        port,
+		Certificate: string(certPEM),
+		PrivateKey:  string(keyPEM),
+		Clients:     []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e1"}},
 	}
-	m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
+
+	m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
 	t.Cleanup(m.StopAll)
 
 	if err := m.Ensure(inst); err != nil {
-		t.Fatalf("Ensure: %v", err)
+		t.Fatalf("Ensure failed: %v", err)
+	}
+
+	if !m.HasRunning() {
+		t.Fatal("expected manager to have running server")
 	}
-	if c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}); err == nil {
+
+	// Port should be taken by the server
+	if c, err := net.ListenPacket("udp", inst.BindTo()); err == nil {
 		_ = c.Close()
-		t.Fatal("the relay must own the inbound's public port while the sidecar runs")
+		t.Fatal("expected server to be listening on port")
+	}
+
+	// Reconcile with empty list should remove it
+	m.Reconcile([]Instance{})
+	if m.HasRunning() {
+		t.Fatal("expected no running servers after reconcile empty")
+	}
+
+	// Port should now be released
+	c, err := net.ListenPacket("udp", inst.BindTo())
+	if err != nil {
+		t.Fatalf("expected port to be free after reconcile: %v", err)
 	}
-	raw, err := os.ReadFile(ConfigPathForID(7))
+	_ = c.Close()
+}
+
+func TestEnsureHotUpdatesUsersWithoutRestart(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
 	if err != nil {
 		t.Fatal(err)
 	}
-	var cfg struct {
-		Server string `json:"server"`
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	_ = pc.Close()
+
+	inst := Instance{
+		Id:          12,
+		Tag:         "tuic-12",
+		Listen:      "127.0.0.1",
+		Port:        port,
+		Certificate: string(certPEM),
+		PrivateKey:  string(keyPEM),
+		Clients:     []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p1", Email: "e1"}},
+	}
+
+	m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
+	t.Cleanup(m.StopAll)
+
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure failed: %v", err)
 	}
-	if err := json.Unmarshal(raw, &cfg); err != nil {
-		t.Fatal(err)
+
+	server1 := m.servers[12].server
+
+	// Update user list without changing port or certs
+	inst.Clients = append(inst.Clients, TuicClientSettings{
+		UUID:     "a0000000-0000-0000-0000-000000000002",
+		Password: "p2",
+		Email:    "e2",
+	})
+
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure with updated clients failed: %v", err)
 	}
-	host, sidecarPort, err := net.SplitHostPort(cfg.Server)
-	if err != nil || host != "127.0.0.1" || sidecarPort == "" || cfg.Server == inst.BindTo() {
-		t.Fatalf("sidecar bound to %q, want a loopback port other than the public %q", cfg.Server, inst.BindTo())
+
+	server2 := m.servers[12].server
+	if server1 != server2 {
+		t.Fatal("expected server instance to be reused across user updates (zero-downtime hot update)")
 	}
 
-	m.Remove(7)
-	c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
-	if err != nil {
-		t.Fatalf("public port still held after Remove: %v", err)
+	// Verify both users are now in user registry
+	if len(server2.users.users) != 2 {
+		t.Fatalf("expected 2 users in registry, got %d", len(server2.users.users))
 	}
-	_ = c.Close()
 }
 
 func TestEnsureUpdatesTagWithoutRestart(t *testing.T) {
-	if runtime.GOOS == "windows" {
-		t.Skip("uses a shell script as the sidecar binary")
-	}
-	bin := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", bin)
-	if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
-		t.Fatal(err)
-	}
-	port, err := freeLoopbackUDPPort()
+	certPEM, keyPEM := generateTestCert(t)
+
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
 	if err != nil {
 		t.Fatal(err)
 	}
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	_ = pc.Close()
+
 	inst := Instance{
-		Id: 8, Tag: "old-tag", Listen: "127.0.0.1", Port: port,
-		Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
+		Id:          13,
+		Tag:         "old-tag",
+		Listen:      "127.0.0.1",
+		Port:        port,
+		Certificate: string(certPEM),
+		PrivateKey:  string(keyPEM),
+		Clients:     []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e"}},
 	}
-	m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
+
+	m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
 	t.Cleanup(m.StopAll)
 
 	if err := m.Ensure(inst); err != nil {
-		t.Fatalf("Ensure: %v", err)
+		t.Fatalf("Ensure failed: %v", err)
 	}
+
 	inst.Tag = "new-tag"
 	if err := m.Ensure(inst); err != nil {
-		t.Fatalf("Ensure updated tag: %v", err)
+		t.Fatalf("Ensure updated tag failed: %v", err)
 	}
+
 	m.mu.Lock()
-	gotTag := m.procs[8].tag
+	gotTag := m.servers[13].tag
 	m.mu.Unlock()
 	if gotTag != "new-tag" {
 		t.Fatalf("manager tag = %q, want %q", gotTag, "new-tag")
 	}
 }
+
+func TestEnsureUpdatesControllerWithoutRestartForNewConnections(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	_ = pc.Close()
+
+	inst := Instance{
+		Id:                    14,
+		Tag:                   "tuic-controller-reload",
+		Listen:                "127.0.0.1",
+		Port:                  port,
+		Certificate:           string(certPEM),
+		PrivateKey:            string(keyPEM),
+		CongestionControl:     "bbr",
+		LogLevel:              "debug",
+		MaxIdleTime:           30,
+		AuthenticationTimeout: 30,
+		Clients:               []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e"}},
+	}
+	m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
+	t.Cleanup(m.StopAll)
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure failed: %v", err)
+	}
+	server := m.servers[inst.Id].server
+
+	dial := func() *quic.Conn {
+		t.Helper()
+		ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+		defer cancel()
+		conn, err := quic.DialAddr(ctx, server.packetConn.LocalAddr().String(), &tls.Config{
+			InsecureSkipVerify: true,
+			NextProtos:         []string{"h3"},
+		}, &quic.Config{EnableDatagrams: true, MaxIdleTimeout: 30 * time.Second})
+		if err != nil {
+			t.Fatalf("QUIC dial failed: %v", err)
+		}
+		return conn
+	}
+
+	connBBR := dial()
+	defer connBBR.CloseWithError(0, "")
+	waitForTuicLog(t, "applied bbr congestion controller")
+	bbrSender := waitForClientCongestionSender(t, server, connBBR, "bbr")
+
+	inst.CongestionControl = "cubic"
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure after CUBIC update failed: %v", err)
+	}
+	if m.servers[inst.Id].server != server {
+		t.Fatal("changing congestion control restarted the listener")
+	}
+	if err := connBBR.Context().Err(); err != nil {
+		t.Fatalf("existing BBR connection closed after controller update: %v", err)
+	}
+	if sender := congestionSenderForClient(t, server, connBBR); sender != bbrSender {
+		t.Fatal("existing connection's BBR sender changed after hot update")
+	}
+	connCubic := dial()
+	defer connCubic.CloseWithError(0, "")
+	waitForTuicLog(t, "cubic is not available; applied new_reno congestion controller")
+	cubicSender := waitForClientCongestionSender(t, server, connCubic, "new_reno")
+
+	inst.CongestionControl = "new_reno"
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure after New Reno update failed: %v", err)
+	}
+	if err := connBBR.Context().Err(); err != nil {
+		t.Fatalf("existing BBR connection closed after second update: %v", err)
+	}
+	if err := connCubic.Context().Err(); err != nil {
+		t.Fatalf("existing CUBIC connection closed after second update: %v", err)
+	}
+	if sender := congestionSenderForClient(t, server, connBBR); sender != bbrSender {
+		t.Fatal("existing connection's BBR sender changed after second hot update")
+	}
+	if sender := congestionSenderForClient(t, server, connCubic); sender != cubicSender {
+		t.Fatal("existing connection's CUBIC sender changed after second hot update")
+	}
+	connReno := dial()
+	defer connReno.CloseWithError(0, "")
+	waitForTuicLog(t, "applied new_reno congestion controller")
+	waitForClientCongestionSender(t, server, connReno, "new_reno")
+}
+
+func waitForClientCongestionSender(t *testing.T, server *Server, client *quic.Conn, want string) uintptr {
+	t.Helper()
+	deadline := time.Now().Add(4 * time.Second)
+	var observed []string
+	seen := make(map[string]struct{})
+	for time.Now().Before(deadline) {
+		server.connectionsMu.Lock()
+		for conn := range server.connections {
+			remote := conn.RemoteAddr().String()
+			actual, sender := inspectCongestionSender(conn)
+			description := fmt.Sprintf("remote=%s controller=%s sender=%x", remote, actual, sender)
+			if _, exists := seen[description]; !exists {
+				seen[description] = struct{}{}
+				observed = append(observed, description)
+			}
+			if !matchesClientSocket(conn, client) {
+				continue
+			}
+			if actual == want {
+				server.connectionsMu.Unlock()
+				return sender
+			}
+		}
+		server.connectionsMu.Unlock()
+		time.Sleep(5 * time.Millisecond)
+	}
+	t.Fatalf("server connection did not install %s congestion sender for client %s (observed %v)", want, client.LocalAddr(), observed)
+	return 0
+}
+
+func congestionSenderForClient(t *testing.T, server *Server, client *quic.Conn) uintptr {
+	t.Helper()
+	server.connectionsMu.Lock()
+	defer server.connectionsMu.Unlock()
+	for conn := range server.connections {
+		if matchesClientSocket(conn, client) {
+			_, sender := inspectCongestionSender(conn)
+			return sender
+		}
+	}
+	t.Fatal("server connection for client is not registered")
+	return 0
+}
+
+func matchesClientSocket(serverConn, clientConn *quic.Conn) bool {
+	serverAddr, serverOK := serverConn.RemoteAddr().(*net.UDPAddr)
+	clientAddr, clientOK := clientConn.LocalAddr().(*net.UDPAddr)
+	return serverOK && clientOK && serverAddr.Port == clientAddr.Port
+}
+
+// lockedCongestion reads the sender under the mutex SetCongestionControl writes
+// it under, which the BBR install after the handshake races otherwise.
+func lockedCongestion(conn *quic.Conn) (reflect.Value, func()) {
+	handler := reflect.ValueOf(conn).Elem().FieldByName("sentPacketHandler").Elem().Elem()
+	mu := (*sync.RWMutex)(unsafe.Pointer(handler.FieldByName("congestionMutex").UnsafeAddr()))
+	mu.RLock()
+	return handler.FieldByName("congestion").Elem(), mu.RUnlock
+}
+
+func inspectCongestionSender(conn *quic.Conn) (string, uintptr) {
+	controller, unlock := lockedCongestion(conn)
+	defer unlock()
+	sender := controller
+	if controller.Type().String() == "*ackhandler.ccAdapterEx" || controller.Type().String() == "*ackhandler.ccAdapter" {
+		sender = controller.Elem().FieldByName("CC").Elem()
+	}
+	switch {
+	case strings.Contains(sender.Type().String(), "bbrSender"):
+		return "bbr", sender.Pointer()
+	case strings.Contains(sender.Type().String(), "cubicSender"):
+		if sender.Elem().FieldByName("reno").Bool() {
+			return "new_reno", sender.Pointer()
+		}
+		return "cubic", sender.Pointer()
+	}
+	return sender.Type().String(), sender.Pointer()
+}
+
+func waitForTuicLog(t *testing.T, message string) {
+	t.Helper()
+	marker := "inbound 14 (tuic-controller-reload): " + message
+	deadline := time.Now().Add(4 * time.Second)
+	for time.Now().Before(deadline) {
+		if strings.Contains(strings.Join(logger.GetLogs(10000, "DEBUG"), "\n"), marker) {
+			return
+		}
+		time.Sleep(5 * time.Millisecond)
+	}
+	t.Fatalf("timed out waiting for TUIC log %q", marker)
+}
+
+func TestCollectAllTraffic(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	_ = pc.Close()
+
+	inst := Instance{
+		Id:          20,
+		Tag:         "tuic-20",
+		Listen:      "127.0.0.1",
+		Port:        port,
+		Certificate: string(certPEM),
+		PrivateKey:  string(keyPEM),
+		Clients:     []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e1"}},
+	}
+
+	m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
+	t.Cleanup(m.StopAll)
+
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure failed: %v", err)
+	}
+
+	if !m.AddTestTraffic(20, "e1", 500, 1000) {
+		t.Fatal("AddTestTraffic failed")
+	}
+
+	inbounds, clients := m.CollectAllTraffic()
+	if len(inbounds) != 1 || inbounds[0].Up != 500 || inbounds[0].Down != 1000 {
+		t.Fatalf("unexpected inbounds: %+v", inbounds)
+	}
+	if len(clients) != 1 || clients[0].Up != 500 || clients[0].Down != 1000 || clients[0].Email != "e1" {
+		t.Fatalf("unexpected clients: %+v", clients)
+	}
+
+	// Subsequent call returns empty deltas
+	inbounds2, clients2 := m.CollectAllTraffic()
+	if len(inbounds2) != 0 || len(clients2) != 0 {
+		t.Fatalf("expected empty deltas after drain, got %+v, %+v", inbounds2, clients2)
+	}
+}
+
+func TestManagerRequeuesClientTrafficWithoutLosingDeltas(t *testing.T) {
+	m := &Manager{servers: make(map[int]*managed)}
+	m.RequeueClientTraffic([]ClientTrafficDelta{{Email: "[email protected]", Up: 10, Down: 20}})
+	m.RequeueClientTraffic([]ClientTrafficDelta{{Email: "[email protected]", Up: 30, Down: 40}})
+
+	_, got := m.CollectAllTraffic()
+	if len(got) != 1 || got[0] != (ClientTrafficDelta{Email: "[email protected]", Up: 40, Down: 60}) {
+		t.Fatalf("requeued client traffic = %+v", got)
+	}
+	if _, got = m.CollectAllTraffic(); len(got) != 0 {
+		t.Fatalf("requeued traffic was collected more than once: %+v", got)
+	}
+}

+ 0 - 87
internal/tuic/orphans_linux.go

@@ -1,87 +0,0 @@
-//go:build linux
-
-package tuic
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"syscall"
-	"time"
-)
-
-func killStrayTuicProcesses(binaryPath string) int {
-	base := filepath.Base(binaryPath)
-	if base == "" || base == "." || base == string(filepath.Separator) {
-		return 0
-	}
-	configDir := filepath.Clean(ConfigDir())
-	self := os.Getpid()
-	entries, err := os.ReadDir("/proc")
-	if err != nil {
-		return 0
-	}
-	killed := 0
-	for _, e := range entries {
-		pid, err := strconv.Atoi(e.Name())
-		if err != nil || pid == self {
-			continue
-		}
-		if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
-			continue
-		}
-		if !isManagedTuicCmdline(pid, configDir) {
-			continue
-		}
-		if err := syscall.Kill(pid, syscall.SIGTERM); err == nil {
-			killed++
-			time.Sleep(50 * time.Millisecond)
-			if err := syscall.Kill(pid, 0); err == nil {
-				_ = syscall.Kill(pid, syscall.SIGKILL)
-			}
-		}
-	}
-	return killed
-}
-
-func isManagedTuicCmdline(pid int, configDir string) bool {
-	data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
-	if err != nil || len(data) == 0 {
-		return false
-	}
-	args := strings.Split(string(data), "\x00")
-	for i, arg := range args {
-		if arg == "-c" && i+1 < len(args) {
-			cfg := filepath.Clean(args[i+1])
-			if strings.HasPrefix(cfg, configDir) {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-func procExeBase(pid int) string {
-	exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
-	if err != nil {
-		return ""
-	}
-	return filepath.Base(exe)
-}
-
-func cmdlineArgv0Base(pid int) string {
-	data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
-	if err != nil || len(data) == 0 {
-		return ""
-	}
-	first := data
-	for i, b := range data {
-		if b == 0 {
-			first = data[:i]
-			break
-		}
-	}
-	return filepath.Base(string(first))
-}

+ 0 - 5
internal/tuic/orphans_other.go

@@ -1,5 +0,0 @@
-//go:build !linux
-
-package tuic
-
-func killStrayTuicProcesses(_ string) int { return 0 }

+ 0 - 292
internal/tuic/process.go

@@ -1,292 +0,0 @@
-package tuic
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"runtime"
-	"strings"
-	"sync"
-	"sync/atomic"
-	"syscall"
-	"time"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/config"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-)
-
-func GetBinaryName() string {
-	name := fmt.Sprintf("tuic-server-%s-%s", runtime.GOOS, runtime.GOARCH)
-	if runtime.GOOS == "windows" {
-		name += ".exe"
-	}
-	return name
-}
-
-func GetBinaryPath() string {
-	custom := filepath.Join(config.GetBinFolderPath(), GetBinaryName())
-	if _, err := os.Stat(custom); err == nil {
-		return custom
-	}
-	binTuic := filepath.Join(config.GetBinFolderPath(), "tuic-server")
-	if runtime.GOOS == "windows" {
-		binTuic += ".exe"
-	}
-	if _, err := os.Stat(binTuic); err == nil {
-		return binTuic
-	}
-	for _, p := range []string{"/usr/local/bin/tuic-server", "/usr/bin/tuic-server"} {
-		if _, err := os.Stat(p); err == nil {
-			return p
-		}
-	}
-	if path, err := exec.LookPath("tuic-server"); err == nil {
-		return path
-	}
-	return binTuic
-}
-
-var (
-	gracefulStopTimeout = 5 * time.Second
-	forceStopTimeout    = 2 * time.Second
-)
-
-type procLogWriter struct {
-	mu          sync.Mutex
-	label       string
-	buf         string
-	lastLine    string
-	uuidToEmail map[string]string
-	lastActive  map[string]int64
-}
-
-func (w *procLogWriter) Write(p []byte) (int, error) {
-	w.mu.Lock()
-	defer w.mu.Unlock()
-	w.buf += string(p)
-	for {
-		i := strings.IndexByte(w.buf, '\n')
-		if i < 0 {
-			break
-		}
-		line := w.buf[:i]
-		w.buf = w.buf[i+1:]
-		w.emitLocked(line)
-	}
-	return len(p), nil
-}
-
-func (w *procLogWriter) Flush() {
-	w.mu.Lock()
-	defer w.mu.Unlock()
-	if w.buf != "" {
-		line := w.buf
-		w.buf = ""
-		w.emitLocked(line)
-	}
-}
-
-func (w *procLogWriter) emitLocked(line string) {
-	trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
-	if trimmed == "" {
-		return
-	}
-	w.lastLine = trimmed
-	logger.Infof("tuic: tuic-server %s | %s", w.label, trimmed)
-
-	now := time.Now().UnixMilli()
-	lowerLine := strings.ToLower(line)
-	for uuid, email := range w.uuidToEmail {
-		if strings.Contains(lowerLine, uuid) {
-			if w.lastActive == nil {
-				w.lastActive = make(map[string]int64)
-			}
-			w.lastActive[email] = now
-		}
-	}
-}
-
-func (w *procLogWriter) LastLine() string {
-	w.mu.Lock()
-	defer w.mu.Unlock()
-	return w.lastLine
-}
-
-type Process struct {
-	mu              sync.RWMutex
-	cmd             *exec.Cmd
-	done            chan struct{}
-	configPath      string
-	logWriter       *procLogWriter
-	exitErr         error
-	intentionalStop atomic.Bool
-}
-
-func newProcess(configPath, label string, uuidToEmail map[string]string) *Process {
-	return &Process{
-		configPath: configPath,
-		logWriter: &procLogWriter{
-			label:       label,
-			uuidToEmail: uuidToEmail,
-			lastActive:  make(map[string]int64),
-		},
-	}
-}
-
-func (p *Process) GetActiveEmails(window time.Duration) []string {
-	if p == nil || p.logWriter == nil {
-		return nil
-	}
-	p.logWriter.mu.Lock()
-	defer p.logWriter.mu.Unlock()
-	cutoff := time.Now().Add(-window).UnixMilli()
-	var active []string
-	for email, last := range p.logWriter.lastActive {
-		if last >= cutoff {
-			active = append(active, email)
-		}
-	}
-	return active
-}
-
-func (p *Process) UpdateClients(uuidToEmail map[string]string) {
-	if p == nil || p.logWriter == nil {
-		return
-	}
-	p.logWriter.mu.Lock()
-	defer p.logWriter.mu.Unlock()
-	p.logWriter.uuidToEmail = uuidToEmail
-}
-
-func (p *Process) IsRunning() bool {
-	p.mu.RLock()
-	cmd, done := p.cmd, p.done
-	p.mu.RUnlock()
-	if cmd == nil || cmd.Process == nil {
-		return false
-	}
-	if done != nil {
-		select {
-		case <-done:
-			return false
-		default:
-		}
-	}
-	return true
-}
-
-func (p *Process) GetResult() string {
-	if line := p.logWriter.LastLine(); line != "" {
-		return line
-	}
-	p.mu.RLock()
-	exitErr := p.exitErr
-	p.mu.RUnlock()
-	if exitErr != nil {
-		return exitErr.Error()
-	}
-	return ""
-}
-
-func (p *Process) Start() error {
-	if p.IsRunning() {
-		return errors.New("tuic-server is already running")
-	}
-	cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "-c", p.configPath)
-	cmd.Stdout = p.logWriter
-	cmd.Stderr = p.logWriter
-	done := make(chan struct{})
-	p.mu.Lock()
-	p.cmd = cmd
-	p.done = done
-	p.exitErr = nil
-	p.mu.Unlock()
-	p.intentionalStop.Store(false)
-	if err := cmd.Start(); err != nil {
-		close(done)
-		p.mu.Lock()
-		p.cmd = nil
-		p.mu.Unlock()
-		return err
-	}
-	attachChildLifetime(cmd)
-	go p.wait(cmd, done)
-	return nil
-}
-
-func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
-	defer close(done)
-	err := cmd.Wait()
-	p.logWriter.Flush()
-	if err == nil || p.intentionalStop.Load() {
-		return
-	}
-	if runtime.GOOS == "windows" {
-		if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
-			p.setExitErr(err)
-			return
-		}
-	}
-	logger.Errorf("tuic: tuic-server process exited: %v", err)
-	p.setExitErr(err)
-}
-
-func (p *Process) setExitErr(err error) {
-	p.mu.Lock()
-	p.exitErr = err
-	p.mu.Unlock()
-}
-
-func (p *Process) Stop() error {
-	if !p.IsRunning() {
-		return errors.New("tuic-server is not running")
-	}
-	p.intentionalStop.Store(true)
-	p.mu.RLock()
-	cmd, done := p.cmd, p.done
-	p.mu.RUnlock()
-	if cmd == nil || cmd.Process == nil {
-		return errors.New("tuic-server is not running")
-	}
-
-	if runtime.GOOS == "windows" {
-		if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
-			return err
-		}
-		return waitForExit(done, forceStopTimeout)
-	}
-
-	if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
-		if errors.Is(err, os.ErrProcessDone) {
-			return waitForExit(done, forceStopTimeout)
-		}
-		return err
-	}
-
-	if err := waitForExit(done, gracefulStopTimeout); err == nil {
-		return nil
-	}
-
-	logger.Warning("tuic: tuic-server did not stop after SIGTERM, killing process")
-	if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
-		return err
-	}
-	return waitForExit(done, forceStopTimeout)
-}
-
-func waitForExit(done <-chan struct{}, timeout time.Duration) error {
-	if done == nil {
-		return nil
-	}
-	timer := time.NewTimer(timeout)
-	defer timer.Stop()
-	select {
-	case <-done:
-		return nil
-	case <-timer.C:
-		return fmt.Errorf("timed out waiting for tuic-server process to stop after %s", timeout)
-	}
-}

+ 0 - 7
internal/tuic/process_other.go

@@ -1,7 +0,0 @@
-//go:build !windows
-
-package tuic
-
-import "os/exec"
-
-func attachChildLifetime(_ *exec.Cmd) {}

+ 0 - 67
internal/tuic/process_windows.go

@@ -1,67 +0,0 @@
-//go:build windows
-
-package tuic
-
-import (
-	"os/exec"
-	"sync"
-	"unsafe"
-
-	"golang.org/x/sys/windows"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-)
-
-var (
-	killOnExitJobOnce sync.Once
-	killOnExitJob     windows.Handle
-	killOnExitJobErr  error
-)
-
-func ensureKillOnExitJob() (windows.Handle, error) {
-	killOnExitJobOnce.Do(func() {
-		h, err := windows.CreateJobObject(nil, nil)
-		if err != nil {
-			killOnExitJobErr = err
-			return
-		}
-		info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
-			BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
-				LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
-			},
-		}
-		_, err = windows.SetInformationJobObject(
-			h,
-			windows.JobObjectExtendedLimitInformation,
-			uintptr(unsafe.Pointer(&info)),
-			uint32(unsafe.Sizeof(info)),
-		)
-		if err != nil {
-			_ = windows.CloseHandle(h)
-			killOnExitJobErr = err
-			return
-		}
-		killOnExitJob = h
-	})
-	return killOnExitJob, killOnExitJobErr
-}
-
-func attachChildLifetime(cmd *exec.Cmd) {
-	if cmd == nil || cmd.Process == nil {
-		return
-	}
-	job, err := ensureKillOnExitJob()
-	if err != nil {
-		logger.Warningf("tuic: kill-on-exit job unavailable: %v", err)
-		return
-	}
-	h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
-	if err != nil {
-		logger.Warningf("tuic: OpenProcess for job attach failed: %v", err)
-		return
-	}
-	defer func() { _ = windows.CloseHandle(h) }()
-	if err := windows.AssignProcessToJobObject(job, h); err != nil {
-		logger.Warningf("tuic: AssignProcessToJobObject failed: %v", err)
-	}
-}

+ 225 - 0
internal/tuic/protocol.go

@@ -0,0 +1,225 @@
+package tuic
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"strconv"
+)
+
+const (
+	// ProtocolVersion is the TUIC protocol version (0x05).
+	ProtocolVersion byte = 0x05
+
+	// Command types
+	CmdAuthenticate byte = 0x00
+	CmdConnect      byte = 0x01
+	CmdPacket       byte = 0x02
+	CmdDissociate   byte = 0x03
+	CmdHeartbeat    byte = 0x04
+
+	// Address types
+	AddrTypeDomain byte = 0x00
+	AddrTypeIPv4   byte = 0x01
+	AddrTypeIPv6   byte = 0x02
+	AddrTypeNone   byte = 0xff
+)
+
+var (
+	ErrInvalidVersion = errors.New("tuic: invalid protocol version")
+	ErrInvalidCmd     = errors.New("tuic: invalid command type")
+	ErrInvalidAddr    = errors.New("tuic: invalid address format")
+)
+
+// Address represents a network endpoint (host + port) in TUIC v5.
+type Address struct {
+	Type byte
+	Host string
+	IP   net.IP
+	Port uint16
+}
+
+// String returns "host:port" suitable for net.Dial.
+func (a *Address) String() string {
+	if a == nil || a.Type == AddrTypeNone {
+		return ""
+	}
+	if len(a.IP) > 0 {
+		return net.JoinHostPort(a.IP.String(), strconv.Itoa(int(a.Port)))
+	}
+	return net.JoinHostPort(a.Host, strconv.Itoa(int(a.Port)))
+}
+
+// ReadAddress decodes a TUIC v5 address from the reader.
+func ReadAddress(r io.Reader) (*Address, error) {
+	var typeBuf [1]byte
+	if _, err := io.ReadFull(r, typeBuf[:]); err != nil {
+		return nil, err
+	}
+	addrType := typeBuf[0]
+
+	if addrType == AddrTypeNone {
+		return &Address{Type: AddrTypeNone}, nil
+	}
+
+	addr := &Address{Type: addrType}
+
+	switch addrType {
+	case AddrTypeIPv4:
+		var ip [4]byte
+		if _, err := io.ReadFull(r, ip[:]); err != nil {
+			return nil, err
+		}
+		addr.IP = net.IP(ip[:])
+		addr.Host = addr.IP.String()
+
+	case AddrTypeIPv6:
+		var ip [16]byte
+		if _, err := io.ReadFull(r, ip[:]); err != nil {
+			return nil, err
+		}
+		addr.IP = net.IP(ip[:])
+		addr.Host = addr.IP.String()
+
+	case AddrTypeDomain:
+		var lenBuf [1]byte
+		if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
+			return nil, err
+		}
+		dLen := int(lenBuf[0])
+		if dLen == 0 {
+			return nil, ErrInvalidAddr
+		}
+		domainBuf := make([]byte, dLen)
+		if _, err := io.ReadFull(r, domainBuf); err != nil {
+			return nil, err
+		}
+		addr.Host = string(domainBuf)
+
+	default:
+		return nil, fmt.Errorf("%w: unknown type 0x%02x", ErrInvalidAddr, addrType)
+	}
+
+	var portBuf [2]byte
+	if _, err := io.ReadFull(r, portBuf[:]); err != nil {
+		return nil, err
+	}
+	addr.Port = binary.BigEndian.Uint16(portBuf[:])
+
+	return addr, nil
+}
+
+// WriteAddress encodes a TUIC v5 address to the writer.
+func WriteAddress(w io.Writer, addr *Address) error {
+	if addr == nil || addr.Type == AddrTypeNone {
+		_, err := w.Write([]byte{AddrTypeNone})
+		return err
+	}
+
+	var buf []byte
+	switch addr.Type {
+	case AddrTypeIPv4:
+		ip4 := addr.IP.To4()
+		if len(ip4) != 4 {
+			return ErrInvalidAddr
+		}
+		buf = make([]byte, 1+4+2)
+		buf[0] = AddrTypeIPv4
+		copy(buf[1:5], ip4)
+		binary.BigEndian.PutUint16(buf[5:7], addr.Port)
+
+	case AddrTypeIPv6:
+		ip16 := addr.IP.To16()
+		if len(ip16) != 16 {
+			return ErrInvalidAddr
+		}
+		buf = make([]byte, 1+16+2)
+		buf[0] = AddrTypeIPv6
+		copy(buf[1:17], ip16)
+		binary.BigEndian.PutUint16(buf[17:19], addr.Port)
+
+	case AddrTypeDomain:
+		dLen := len(addr.Host)
+		if dLen == 0 || dLen > 255 {
+			return ErrInvalidAddr
+		}
+		buf = make([]byte, 1+1+dLen+2)
+		buf[0] = AddrTypeDomain
+		buf[1] = byte(dLen)
+		copy(buf[2:2+dLen], []byte(addr.Host))
+		binary.BigEndian.PutUint16(buf[2+dLen:4+dLen], addr.Port)
+
+	default:
+		return ErrInvalidAddr
+	}
+
+	_, err := w.Write(buf)
+	return err
+}
+
+// ReadCommand reads the 2-byte TUIC command header: [VER (1)][TYPE (1)].
+func ReadCommand(r io.Reader) (byte, byte, error) {
+	var hdr [2]byte
+	if _, err := io.ReadFull(r, hdr[:]); err != nil {
+		return 0, 0, err
+	}
+	if hdr[0] != ProtocolVersion {
+		return hdr[0], hdr[1], fmt.Errorf("%w: got 0x%02x, want 0x%02x", ErrInvalidVersion, hdr[0], ProtocolVersion)
+	}
+	return hdr[0], hdr[1], nil
+}
+
+// PacketHeader represents the header of a UDP Packet command (0x02).
+type PacketHeader struct {
+	AssocID   uint16
+	PktID     uint16
+	FragTotal uint8
+	FragID    uint8
+	Size      uint16
+	Addr      *Address
+}
+
+// ReadPacketHeader reads the packet command fields following [VER][0x02].
+func ReadPacketHeader(r io.Reader) (*PacketHeader, error) {
+	var fixed [8]byte
+	if _, err := io.ReadFull(r, fixed[:]); err != nil {
+		return nil, err
+	}
+	ph := &PacketHeader{
+		AssocID:   binary.BigEndian.Uint16(fixed[0:2]),
+		PktID:     binary.BigEndian.Uint16(fixed[2:4]),
+		FragTotal: fixed[4],
+		FragID:    fixed[5],
+		Size:      binary.BigEndian.Uint16(fixed[6:8]),
+	}
+
+	addr, err := ReadAddress(r)
+	if err != nil {
+		return nil, err
+	}
+	ph.Addr = addr
+	return ph, nil
+}
+
+// WritePacket writes a complete Packet command frame to w.
+func WritePacket(w io.Writer, assocID, pktID uint16, fragTotal, fragID uint8, addr *Address, payload []byte) error {
+	hdr := make([]byte, 10)
+	hdr[0] = ProtocolVersion
+	hdr[1] = CmdPacket
+	binary.BigEndian.PutUint16(hdr[2:4], assocID)
+	binary.BigEndian.PutUint16(hdr[4:6], pktID)
+	hdr[6] = fragTotal
+	hdr[7] = fragID
+	binary.BigEndian.PutUint16(hdr[8:10], uint16(len(payload)))
+
+	if _, err := w.Write(hdr); err != nil {
+		return err
+	}
+	if err := WriteAddress(w, addr); err != nil {
+		return err
+	}
+	_, err := w.Write(payload)
+	return err
+}

+ 145 - 0
internal/tuic/protocol_test.go

@@ -0,0 +1,145 @@
+package tuic
+
+import (
+	"bytes"
+	"net"
+	"reflect"
+	"testing"
+)
+
+func TestAddressEncodingDecoding(t *testing.T) {
+	tests := []struct {
+		name string
+		addr *Address
+	}{
+		{
+			name: "IPv4",
+			addr: &Address{
+				Type: AddrTypeIPv4,
+				IP:   net.ParseIP("1.2.3.4").To4(),
+				Host: "1.2.3.4",
+				Port: 443,
+			},
+		},
+		{
+			name: "IPv6",
+			addr: &Address{
+				Type: AddrTypeIPv6,
+				IP:   net.ParseIP("2001:db8::1"),
+				Host: "2001:db8::1",
+				Port: 8080,
+			},
+		},
+		{
+			name: "Domain",
+			addr: &Address{
+				Type: AddrTypeDomain,
+				Host: "example.com",
+				Port: 8443,
+			},
+		},
+		{
+			name: "None",
+			addr: &Address{
+				Type: AddrTypeNone,
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			var buf bytes.Buffer
+			if err := WriteAddress(&buf, tc.addr); err != nil {
+				t.Fatalf("WriteAddress error: %v", err)
+			}
+
+			decoded, err := ReadAddress(&buf)
+			if err != nil {
+				t.Fatalf("ReadAddress error: %v", err)
+			}
+
+			if tc.addr.Type == AddrTypeNone {
+				if decoded.Type != AddrTypeNone {
+					t.Fatalf("expected None type, got %v", decoded.Type)
+				}
+				return
+			}
+
+			if decoded.Type != tc.addr.Type {
+				t.Errorf("Type mismatch: got %v, want %v", decoded.Type, tc.addr.Type)
+			}
+			if decoded.Port != tc.addr.Port {
+				t.Errorf("Port mismatch: got %v, want %v", decoded.Port, tc.addr.Port)
+			}
+			if tc.addr.Type == AddrTypeDomain {
+				if decoded.Host != tc.addr.Host {
+					t.Errorf("Host mismatch: got %v, want %v", decoded.Host, tc.addr.Host)
+				}
+			} else {
+				if !decoded.IP.Equal(tc.addr.IP) {
+					t.Errorf("IP mismatch: got %v, want %v", decoded.IP, tc.addr.IP)
+				}
+			}
+		})
+	}
+}
+
+func TestCommandHeader(t *testing.T) {
+	buf := bytes.NewBuffer([]byte{0x05, 0x01})
+	ver, cmd, err := ReadCommand(buf)
+	if err != nil {
+		t.Fatalf("ReadCommand error: %v", err)
+	}
+	if ver != ProtocolVersion || cmd != CmdConnect {
+		t.Fatalf("got ver=%d, cmd=%d; want ver=5, cmd=1", ver, cmd)
+	}
+
+	invalidBuf := bytes.NewBuffer([]byte{0x04, 0x01})
+	_, _, err = ReadCommand(invalidBuf)
+	if err == nil {
+		t.Fatal("expected error on invalid version, got nil")
+	}
+}
+
+func TestPacketHeaderAndPayload(t *testing.T) {
+	var buf bytes.Buffer
+	target := &Address{
+		Type: AddrTypeDomain,
+		Host: "dns.google",
+		Port: 53,
+	}
+	payload := []byte("hello-udp")
+
+	err := WritePacket(&buf, 100, 1, 1, 0, target, payload)
+	if err != nil {
+		t.Fatalf("WritePacket error: %v", err)
+	}
+
+	ver, cmd, err := ReadCommand(&buf)
+	if err != nil {
+		t.Fatalf("ReadCommand error: %v", err)
+	}
+	if ver != ProtocolVersion || cmd != CmdPacket {
+		t.Fatalf("got ver=%d cmd=%d, want 5 and 2", ver, cmd)
+	}
+
+	ph, err := ReadPacketHeader(&buf)
+	if err != nil {
+		t.Fatalf("ReadPacketHeader error: %v", err)
+	}
+
+	if ph.AssocID != 100 || ph.PktID != 1 || ph.FragTotal != 1 || ph.FragID != 0 {
+		t.Fatalf("PacketHeader mismatch: %+v", ph)
+	}
+	if ph.Addr.Host != "dns.google" || ph.Addr.Port != 53 {
+		t.Fatalf("Packet address mismatch: %+v", ph.Addr)
+	}
+
+	readPayload := make([]byte, ph.Size)
+	if _, err := buf.Read(readPayload); err != nil {
+		t.Fatalf("reading payload error: %v", err)
+	}
+	if !reflect.DeepEqual(readPayload, payload) {
+		t.Fatalf("payload mismatch: got %s, want %s", readPayload, payload)
+	}
+}

+ 0 - 211
internal/tuic/relay.go

@@ -1,211 +0,0 @@
-package tuic
-
-import (
-	"errors"
-	"net"
-	"sync"
-	"sync/atomic"
-	"time"
-)
-
-// A QUIC flow the sidecar has not touched for this long is forgotten; QUIC's
-// own max_idle_time (15s by default) closes the session well before that.
-const relayFlowIdle = 2 * time.Minute
-
-const (
-	relaySocketBuffer = 4 << 20
-	maxRelayFlows     = 4096
-)
-
-// udpRelay owns an inbound's public UDP port and counts the bytes it forwards to
-// the sidecar on loopback: tuic-server has no stats API and /proc/io stays at 0.
-type udpRelay struct {
-	public    *net.UDPConn
-	upstream  *net.UDPAddr
-	idle      time.Duration
-	maxFlows  int
-	up        atomic.Int64
-	down      atomic.Int64
-	mu        sync.Mutex
-	flows     map[string]*relayFlow
-	done      chan struct{}
-	closeOnce sync.Once
-	wg        sync.WaitGroup
-}
-
-type relayFlow struct {
-	conn     *net.UDPConn
-	client   *net.UDPAddr
-	lastSeen atomic.Int64
-}
-
-func startUDPRelay(bind string, upstream *net.UDPAddr, idle time.Duration) (*udpRelay, error) {
-	addr, err := net.ResolveUDPAddr("udp", bind)
-	if err != nil {
-		return nil, err
-	}
-	public, err := net.ListenUDP("udp", addr)
-	if err != nil {
-		return nil, err
-	}
-	_ = public.SetReadBuffer(relaySocketBuffer)
-	_ = public.SetWriteBuffer(relaySocketBuffer)
-	r := &udpRelay{
-		public:   public,
-		upstream: upstream,
-		idle:     idle,
-		maxFlows: maxRelayFlows,
-		flows:    make(map[string]*relayFlow),
-		done:     make(chan struct{}),
-	}
-	r.wg.Add(2)
-	go r.serve()
-	go r.sweep()
-	return r, nil
-}
-
-func freeLoopbackUDPPort() (int, error) {
-	c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
-	if err != nil {
-		return 0, err
-	}
-	defer c.Close()
-	return c.LocalAddr().(*net.UDPAddr).Port, nil
-}
-
-func (r *udpRelay) LocalAddr() net.Addr {
-	return r.public.LocalAddr()
-}
-
-// CollectTraffic returns the client-to-sidecar and sidecar-to-client bytes
-// relayed since the previous call.
-func (r *udpRelay) CollectTraffic() (up, down int64) {
-	return r.up.Swap(0), r.down.Swap(0)
-}
-
-func (r *udpRelay) Close() {
-	if r == nil {
-		return
-	}
-	r.closeOnce.Do(func() {
-		close(r.done)
-		_ = r.public.Close()
-		r.mu.Lock()
-		for key, f := range r.flows {
-			_ = f.conn.Close()
-			delete(r.flows, key)
-		}
-		r.mu.Unlock()
-		r.wg.Wait()
-	})
-}
-
-func (r *udpRelay) serve() {
-	defer r.wg.Done()
-	buf := make([]byte, 65535)
-	for {
-		n, client, err := r.public.ReadFromUDP(buf)
-		if err != nil {
-			if errors.Is(err, net.ErrClosed) {
-				return
-			}
-			continue
-		}
-		flow, err := r.flowFor(client)
-		if err != nil {
-			continue
-		}
-		if _, err := flow.conn.Write(buf[:n]); err == nil {
-			r.up.Add(int64(n))
-		}
-	}
-}
-
-func (r *udpRelay) flowFor(client *net.UDPAddr) (*relayFlow, error) {
-	key := client.String()
-	now := time.Now().UnixMilli()
-	r.mu.Lock()
-	defer r.mu.Unlock()
-	select {
-	case <-r.done:
-		return nil, net.ErrClosed
-	default:
-	}
-	if f, ok := r.flows[key]; ok {
-		f.lastSeen.Store(now)
-		return f, nil
-	}
-	if len(r.flows) >= r.maxFlows {
-		r.evictLeastRecentLocked()
-	}
-	conn, err := net.DialUDP("udp", nil, r.upstream)
-	if err != nil {
-		return nil, err
-	}
-	_ = conn.SetReadBuffer(relaySocketBuffer)
-	_ = conn.SetWriteBuffer(relaySocketBuffer)
-	f := &relayFlow{conn: conn, client: client}
-	f.lastSeen.Store(now)
-	r.flows[key] = f
-	r.wg.Add(1)
-	go r.pump(f)
-	return f, nil
-}
-
-// Refusing a newcomer at the cap let 4096 junk datagrams lock every new client
-// out until the sweep; the flow last seen longest ago is the junk one.
-func (r *udpRelay) evictLeastRecentLocked() {
-	var oldestKey string
-	oldest := int64(-1)
-	for key, f := range r.flows {
-		if seen := f.lastSeen.Load(); oldest < 0 || seen < oldest {
-			oldest, oldestKey = seen, key
-		}
-	}
-	if f, ok := r.flows[oldestKey]; ok {
-		_ = f.conn.Close()
-		delete(r.flows, oldestKey)
-	}
-}
-
-func (r *udpRelay) pump(f *relayFlow) {
-	defer r.wg.Done()
-	buf := make([]byte, 65535)
-	for {
-		n, err := f.conn.Read(buf)
-		if err != nil {
-			if errors.Is(err, net.ErrClosed) {
-				return
-			}
-			// ICMP unreachable while the sidecar restarts: drop it, keep the flow.
-			time.Sleep(20 * time.Millisecond)
-			continue
-		}
-		if _, err := r.public.WriteToUDP(buf[:n], f.client); err == nil {
-			r.down.Add(int64(n))
-		}
-		f.lastSeen.Store(time.Now().UnixMilli())
-	}
-}
-
-func (r *udpRelay) sweep() {
-	defer r.wg.Done()
-	ticker := time.NewTicker(r.idle / 2)
-	defer ticker.Stop()
-	for {
-		select {
-		case <-r.done:
-			return
-		case <-ticker.C:
-			cutoff := time.Now().Add(-r.idle).UnixMilli()
-			r.mu.Lock()
-			for key, f := range r.flows {
-				if f.lastSeen.Load() < cutoff {
-					_ = f.conn.Close()
-					delete(r.flows, key)
-				}
-			}
-			r.mu.Unlock()
-		}
-	}
-}

+ 263 - 0
internal/tuic/relay_logging_test.go

@@ -0,0 +1,263 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"net"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	clientquic "github.com/quic-go/quic-go"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+func audit3LogsStart(t *testing.T, level, marker, relayAddr string) (*Server, *clientquic.Conn, uuid.UUID, string, []byte) {
+	t.Helper()
+	cert, key := generateTestCert(t)
+	id := uuid.New()
+	password := "PASSWORD-CANARY-" + marker
+	s, err := NewServer(Instance{
+		Id: 192301, Tag: marker, Listen: "127.0.0.1", Port: 0,
+		Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
+		AuthenticationTimeout: 2, MaxIdleTime: 30, LogLevel: level,
+		Clients: []TuicClientSettings{{UUID: id.String(), Password: password, Email: "[email protected]"}},
+	}, &SocksRelay{Addr: relayAddr, Password: "audit3-socks-pass"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Start(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = s.Close() })
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	c, err := clientquic.DialAddr(ctx, s.packetConn.LocalAddr().String(), &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &clientquic.Config{EnableDatagrams: true})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = c.CloseWithError(0, "audit3 finished") })
+	tlsState := c.ConnectionState().TLS
+	token, err := tlsState.ExportKeyingMaterial(string(id[:]), []byte(password), 32)
+	if err != nil {
+		t.Fatal(err)
+	}
+	auth, err := c.OpenUniStreamSync(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	frame := append([]byte{5, 0}, id[:]...)
+	frame = append(frame, token...)
+	if _, err := auth.Write(frame); err != nil {
+		t.Fatal(err)
+	}
+	if err := auth.Close(); err != nil {
+		t.Fatal(err)
+	}
+	_, _ = authenticatedServerConnection(t, s, id)
+	return s, c, id, password, token
+}
+
+func audit3LogsFor(marker string) string {
+	var lines []string
+	for _, line := range logger.GetLogs(10000, "DEBUG") {
+		if strings.Contains(line, marker) {
+			lines = append(lines, line)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+func TestAudit3RealEventsRespectThresholdAndDoNotExposeSecrets(t *testing.T) {
+	for _, level := range []string{"debug", "info", "warn", "error"} {
+		t.Run(level, func(t *testing.T) {
+			marker := fmt.Sprintf("audit3-logs-%s-%d", level, time.Now().UnixNano())
+			relayAddr, cleanup := startMockSocks5Server(t, "[email protected]", "audit3-socks-pass")
+			defer cleanup()
+			s, c, id, password, token := audit3LogsStart(t, level, marker, relayAddr)
+			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+			defer cancel()
+			payload := "PAYLOAD-CANARY-" + marker
+			u, err := c.OpenStreamSync(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			var frame bytes.Buffer
+			frame.Write([]byte{5, 1})
+			injectedDomain := "audit.invalid-FORGED-ENTRY-" + marker
+			if err := WriteAddress(&frame, &Address{Type: AddrTypeDomain, Host: injectedDomain, Port: 443}); err != nil {
+				t.Fatal(err)
+			}
+			frame.WriteString(payload)
+			if _, err := u.Write(frame.Bytes()); err != nil {
+				t.Fatal(err)
+			}
+			if err := u.Close(); err != nil {
+				t.Fatal(err)
+			}
+			got := make([]byte, len(payload))
+			if _, err := io.ReadFull(u, got); err != nil || string(got) != payload {
+				t.Fatalf("TCP echo %q, %v", got, err)
+			}
+			if _, err := io.Copy(io.Discard, u); err != nil {
+				t.Fatal(err)
+			}
+			u.CancelRead(0)
+			var udp bytes.Buffer
+			if err := WritePacket(&udp, 23456, 1, 1, 0, &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}, []byte(payload)); err != nil {
+				t.Fatal(err)
+			}
+			if err := c.SendDatagram(udp.Bytes()); err != nil {
+				t.Fatal(err)
+			}
+			if _, err := c.ReceiveDatagram(ctx); err != nil {
+				t.Fatal(err)
+			}
+			dissociate, err := c.OpenUniStreamSync(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if _, err := dissociate.Write([]byte{5, 3, 0x5b, 0xa0}); err != nil {
+				t.Fatal(err)
+			}
+			_ = dissociate.Close()
+			// The malformed frame includes traffic content as a canary; it must remain absent from logs.
+			if err := c.SendDatagram(append([]byte{5, 2}, []byte(payload)...)); err != nil {
+				t.Fatal(err)
+			}
+			bad, err := c.OpenStreamSync(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if _, err := bad.Write([]byte{5, 1, 0xff}); err != nil {
+				t.Fatal(err)
+			}
+			if _, err := io.Copy(io.Discard, bad); err != nil {
+				t.Fatal(err)
+			}
+			_ = bad.Close()
+			// Trigger a rejected Authenticate event using a changed token on the authenticated connection.
+			badAuth, err := c.OpenUniStreamSync(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			wrongToken := bytes.Repeat([]byte{0x6d}, 32)
+			badFrame := append([]byte{5, 0}, id[:]...)
+			badFrame = append(badFrame, wrongToken...)
+			if _, err := badAuth.Write(badFrame); err != nil {
+				t.Fatal(err)
+			}
+			_ = badAuth.Close()
+			select {
+			case <-c.Context().Done():
+			case <-ctx.Done():
+				t.Fatal("bad auth did not close connection")
+			}
+			_ = s.packetConn.Close()
+			deadline := time.Now().Add(2 * time.Second)
+			for s.IsRunning() && time.Now().Before(deadline) {
+				time.Sleep(time.Millisecond)
+			}
+			_ = s.Close()
+			logs := audit3LogsFor(marker)
+			for _, secret := range []string{password, id.String(), hex.EncodeToString(id[:]), hex.EncodeToString(token), hex.EncodeToString(wrongToken), payload, injectedDomain} {
+				if strings.Contains(logs, secret) {
+					t.Fatalf("logs expose canary %q", secret)
+				}
+			}
+			wantInfo := level == "debug" || level == "info"
+			wantWarn := level != "error"
+			for _, event := range []string{"listener started", "client authenticated", "TCP relay started", "UDP association 23456 started", "listener stopped"} {
+				if got := strings.Contains(logs, "): "+event); got != wantInfo {
+					t.Errorf("event %q present=%t, want %t\n%s", event, got, wantInfo, logs)
+				}
+			}
+			for _, event := range []string{"TCP relay failed", "client authentication rejected"} {
+				if got := strings.Contains(logs, event); got != wantWarn {
+					t.Errorf("event %q present=%t, want %t\n%s", event, got, wantWarn, logs)
+				}
+			}
+			if got := strings.Contains(logs, "applied bbr congestion controller"); got != (level == "debug") {
+				t.Errorf("debug controller event=%t", got)
+			}
+			if !strings.Contains(logs, "QUIC listener stopped accepting connections") {
+				t.Errorf("actual listener error event missing at %s\n%s", level, logs)
+			}
+			t.Logf("actual logger events at %s: %d", level, strings.Count(logs, "tuic: inbound"))
+		})
+	}
+}
+
+func TestAudit3TCPFailuresMustNotFloodPanelLogs(t *testing.T) {
+	marker := fmt.Sprintf("audit3-flood-%d", time.Now().UnixNano())
+	relayAddr, cleanup := startMockSocks5Server(t, "[email protected]", "audit3-socks-pass")
+	defer cleanup()
+	_, c, _, _, _ := audit3LogsStart(t, "warn", marker, relayAddr)
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	for i := 0; i < 110; i++ {
+		u, err := c.OpenStreamSync(ctx)
+		if err != nil {
+			t.Fatalf("CONNECT%d: %v", i, err)
+		}
+		if _, err := u.Write([]byte{5, 1, 0xff}); err != nil {
+			t.Fatal(err)
+		}
+		if _, err := io.Copy(io.Discard, u); err != nil {
+			t.Fatal(err)
+		}
+		_ = u.Close()
+	}
+	count := strings.Count(audit3LogsFor(marker), "TCP relay failed")
+	if count != 1 {
+		t.Fatalf("one authenticated QUIC connection emitted %d TCP failure warnings for 110 commands; expected a bounded warning category", count)
+	}
+}
+
+func TestAudit3BiStreamCreditKeepsTCPEchoUsable(t *testing.T) {
+	marker := fmt.Sprintf("audit3-credit-%d", time.Now().UnixNano())
+	relayAddr, cleanup := startMockSocks5Server(t, "[email protected]", "audit3-socks-pass")
+	defer cleanup()
+	_, c, _, _, _ := audit3LogsStart(t, "error", marker, relayAddr)
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	for i := 0; i < 110; i++ {
+		openCtx, openCancel := context.WithTimeout(ctx, 700*time.Millisecond)
+		u, err := c.OpenStreamSync(openCtx)
+		openCancel()
+		if err != nil {
+			t.Fatalf("malformed stream%d open: %v", i, err)
+		}
+		if _, err := u.Write([]byte{5, 0xff}); err != nil {
+			t.Fatal(err)
+		}
+		if _, err := io.Copy(io.Discard, u); err != nil {
+			t.Fatal(err)
+		}
+		_ = u.Close()
+	}
+	u, err := c.OpenStreamSync(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var frame bytes.Buffer
+	frame.Write([]byte{5, 1})
+	if err := WriteAddress(&frame, &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 443}); err != nil {
+		t.Fatal(err)
+	}
+	frame.WriteString("after-credit-errors")
+	if _, err := u.Write(frame.Bytes()); err != nil {
+		t.Fatal(err)
+	}
+	_ = u.Close()
+	result, err := io.ReadAll(u)
+	if err != nil || string(result) != "after-credit-errors" {
+		t.Fatalf("subsequent real TCP relay result=%q err=%v", result, err)
+	}
+}

+ 243 - 0
internal/tuic/relay_recovery_test.go

@@ -0,0 +1,243 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"io"
+	"net"
+	"runtime"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+
+	clientquic "github.com/quic-go/quic-go"
+)
+
+func audit3RestartableSOCKS(t *testing.T) (string, *net.UDPConn, *net.UDPAddr) {
+	t.Helper()
+	u, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+	if err != nil {
+		t.Fatal(err)
+	}
+	udpAddr := u.LocalAddr().(*net.UDPAddr)
+	ln, err := net.Listen("tcp4", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = ln.Close(); _ = u.Close() })
+	go func() {
+		for {
+			c, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			go func(c net.Conn) {
+				defer c.Close()
+				var greet [4]byte
+				if _, err := io.ReadFull(c, greet[:]); err != nil {
+					return
+				}
+				if _, err := c.Write([]byte{5, 0}); err != nil {
+					return
+				}
+				var req [10]byte
+				if _, err := io.ReadFull(c, req[:]); err != nil {
+					return
+				}
+				reply := []byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}
+				binary.BigEndian.PutUint16(reply[8:], uint16(udpAddr.Port))
+				if _, err := c.Write(reply); err != nil {
+					return
+				}
+				_, _ = io.Copy(io.Discard, c)
+			}(c)
+		}
+	}()
+	return ln.Addr().String(), u, udpAddr
+}
+
+func audit3StartUDPEcho(u *net.UDPConn, arrived chan<- struct{}) {
+	go func() {
+		buf := make([]byte, 2048)
+		for {
+			n, src, err := u.ReadFromUDP(buf)
+			if err != nil {
+				return
+			}
+			_, _ = u.WriteToUDP(buf[:n], src)
+			if arrived != nil {
+				select {
+				case arrived <- struct{}{}:
+				default:
+				}
+			}
+		}
+	}()
+}
+
+func audit3SendPacket(t *testing.T, c *clientquic.Conn, mode uint8, assoc, pkt uint16, payload string) {
+	t.Helper()
+	var b bytes.Buffer
+	if err := WritePacket(&b, assoc, pkt, 1, 0, &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}, []byte(payload)); err != nil {
+		t.Fatal(err)
+	}
+	if mode == packetTransportDatagram {
+		if err := c.SendDatagram(b.Bytes()); err != nil {
+			t.Fatal(err)
+		}
+		return
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	s, err := c.OpenUniStreamSync(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := s.Write(b.Bytes()); err != nil {
+		t.Fatal(err)
+	}
+	_ = s.Close()
+}
+
+func audit3ReceivePacket(c *clientquic.Conn, mode uint8, duration time.Duration) (*PacketHeader, []byte, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), duration)
+	defer cancel()
+	var r io.Reader
+	if mode == packetTransportDatagram {
+		b, err := c.ReceiveDatagram(ctx)
+		if err != nil {
+			return nil, nil, err
+		}
+		r = bytes.NewReader(b)
+	} else {
+		s, err := c.AcceptUniStream(ctx)
+		if err != nil {
+			return nil, nil, err
+		}
+		defer s.CancelRead(0)
+		_ = s.SetReadDeadline(time.Now().Add(duration))
+		r = s
+	}
+	if _, _, err := ReadCommand(r); err != nil {
+		return nil, nil, err
+	}
+	h, err := ReadPacketHeader(r)
+	if err != nil {
+		return nil, nil, err
+	}
+	p, err := readPacketPayload(r, h)
+	return h, p, err
+}
+
+func TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails a read there")
+	}
+	for _, mode := range []uint8{packetTransportDatagram, packetTransportStream} {
+		name := "datagram"
+		if mode == packetTransportStream {
+			name = "stream"
+		}
+		t.Run(name, func(t *testing.T) {
+			relayAddr, u, udpAddr := audit3RestartableSOCKS(t)
+			audit3StartUDPEcho(u, nil)
+			s, c, id, _ := startLifecycleTestServer(t, relayAddr, "audit3-recovery@x")
+			_, user := authenticatedServerConnection(t, s, id)
+			audit3SendPacket(t, c, mode, 42131, 1, "before")
+			if _, p, err := audit3ReceivePacket(c, mode, time.Second); err != nil || string(p) != "before" {
+				t.Fatalf("initial echo %q %v", p, err)
+			}
+
+			s.UpdateRuntimeSettings("recovery-"+name, "bbr", "warn")
+			_ = u.Close()
+			audit3SendPacket(t, c, mode, 42131, 2, "while-down")
+			deadline := time.Now().Add(2 * time.Second)
+			for {
+				found := false
+				for _, line := range logger.GetLogs(10000, "DEBUG") {
+					if strings.Contains(line, "recovery-"+name) && strings.Contains(line, "UDP relay receive failed") {
+						found = true
+						break
+					}
+				}
+				if found {
+					break
+				}
+				if time.Now().After(deadline) {
+					t.Fatal("closed UDP bridge did not terminate the response reader")
+				}
+				time.Sleep(time.Millisecond)
+			}
+			u2, err := net.ListenUDP("udp4", udpAddr)
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { _ = u2.Close() })
+			arrived := make(chan struct{}, 4)
+			audit3StartUDPEcho(u2, arrived)
+			audit3SendPacket(t, c, mode, 42131, 3, "after")
+			select {
+			case <-arrived:
+			case <-time.After(time.Second):
+				t.Fatal("restarted bridge did not receive the retained association request")
+			}
+			_, payload, oldErr := audit3ReceivePacket(c, mode, 300*time.Millisecond)
+			// A different association proves QUIC, the restarted SOCKS bridge, and both transport modes remain functional.
+			audit3SendPacket(t, c, mode, 42132, 4, "fresh")
+			h, p, newErr := audit3ReceivePacket(c, mode, time.Second)
+			if newErr != nil || h.AssocID != 42132 || string(p) != "fresh" {
+				t.Fatalf("fresh association probe %v %q %v", h, p, newErr)
+			}
+			if oldErr != nil || string(payload) != "after" {
+				t.Fatalf("retained association became receive blackhole after read failure: response %q err=%v; fresh association works; user up=%d down=%d", payload, oldErr, user.Traffic.BytesUp.Load(), user.Traffic.BytesDown.Load())
+			}
+		})
+	}
+}
+
+func TestAudit3FirstTransportReturnsPositiveEchoAfterOppositeModePacket(t *testing.T) {
+	for _, first := range []uint8{packetTransportDatagram, packetTransportStream} {
+		name := "datagram-first"
+		if first == packetTransportStream {
+			name = "stream-first"
+		}
+		t.Run(name, func(t *testing.T) {
+			relayAddr, u, _ := audit3RestartableSOCKS(t)
+			audit3StartUDPEcho(u, nil)
+			_, c, _, _ := startLifecycleTestServer(t, relayAddr, "audit3-first-mode@x")
+			audit3SendPacket(t, c, first, 43221, 1, "first")
+			if _, p, err := audit3ReceivePacket(c, first, time.Second); err != nil || string(p) != "first" {
+				t.Fatalf("initial mode echo %q %v", p, err)
+			}
+			audit3SendPacket(t, c, 1-first, 43221, 2, "opposite")
+			if _, p, err := audit3ReceivePacket(c, first, time.Second); err != nil || string(p) != "opposite" {
+				t.Fatalf("opposite request response did not keep first transport: %q %v", p, err)
+			}
+		})
+	}
+}
+
+func TestUDPReaderCleanupCannotDeleteReplacementAssociation(t *testing.T) {
+	registry := newUdpAssociationRegistry(1500)
+	header := &PacketHeader{AssocID: 7, FragTotal: 1, Size: 1, Addr: &Address{Type: AddrTypeIPv4, IP: net.IPv4(1, 1, 1, 1), Port: 53}}
+	old, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("a"))
+	if !complete {
+		t.Fatal("first packet incomplete")
+	}
+	registry.dissociate(7)
+	replacement, _, _, complete := registry.feed(packetTransportStream, header, []byte("b"))
+	if !complete || replacement == old {
+		t.Fatal("association generation was reused")
+	}
+	registry.release(7, old)
+	if !registry.touch(7, replacement, time.Now()) {
+		t.Fatal("late reader cleanup deleted the new association")
+	}
+	registry.release(7, replacement)
+	if registry.touch(7, replacement, time.Now()) {
+		t.Fatal("current reader cleanup retained dead association")
+	}
+}

+ 88 - 0
internal/tuic/relay_shutdown_test.go

@@ -0,0 +1,88 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"net"
+	"testing"
+	"time"
+)
+
+func TestAudit3CloseMustInterruptIdleTCPRelay(t *testing.T) {
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer listener.Close()
+	release := make(chan struct{})
+	defer close(release)
+	peerFIN := make(chan struct{})
+	ready := make(chan struct{})
+	go func() {
+		c, err := listener.Accept()
+		if err != nil {
+			return
+		}
+		defer c.Close()
+		var greeting [4]byte
+		if _, err := io.ReadFull(c, greeting[:]); err != nil {
+			return
+		}
+		c.Write([]byte{5, 0})
+		var req [10]byte
+		if _, err := io.ReadFull(c, req[:]); err != nil {
+			return
+		}
+		c.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
+		var payload [1]byte
+		if _, err := io.ReadFull(c, payload[:]); err != nil {
+			return
+		}
+		c.Write(payload[:])
+		close(ready)
+		io.Copy(io.Discard, c)
+		close(peerFIN)
+		<-release
+	}()
+	server, conn, _, _ := startLifecycleTestServer(t, listener.Addr().String(), "close-idle@audit3")
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	stream, err := conn.OpenStreamSync(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var cmd bytes.Buffer
+	cmd.Write([]byte{ProtocolVersion, CmdConnect})
+	WriteAddress(&cmd, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 443})
+	cmd.WriteByte('x')
+	if _, err := stream.Write(cmd.Bytes()); err != nil {
+		t.Fatal(err)
+	}
+	var echo [1]byte
+	if _, err := io.ReadFull(stream, echo[:]); err != nil {
+		t.Fatal(err)
+	}
+	<-ready
+	closed := make(chan error, 1)
+	started := time.Now()
+	go func() { closed <- server.Close() }()
+
+	select {
+	case err := <-closed:
+		if err != nil {
+			t.Fatal(err)
+		}
+	case <-time.After(time.Second):
+		t.Fatalf("Server.Close waited for idle TCP peer after %s", time.Since(started))
+	}
+	select {
+	case <-peerFIN:
+	case <-time.After(time.Second):
+		t.Fatal("upstream socket did not close")
+	}
+	_, _, deltas := server.CollectAllTraffic()
+	if len(deltas) != 1 || deltas[0].Up != 1 || deltas[0].Down != 1 {
+		t.Fatalf("final traffic: %+v", deltas)
+	}
+}

+ 0 - 150
internal/tuic/relay_test.go

@@ -1,150 +0,0 @@
-package tuic
-
-import (
-	"bytes"
-	"net"
-	"testing"
-	"time"
-)
-
-// doublingEcho answers every datagram with the payload repeated twice, so a
-// relay that mislabels directions or clients cannot pass by accident.
-func doublingEcho(t *testing.T) *net.UDPAddr {
-	t.Helper()
-	echo, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
-	if err != nil {
-		t.Fatal(err)
-	}
-	t.Cleanup(func() { _ = echo.Close() })
-	go func() {
-		buf := make([]byte, 65535)
-		for {
-			n, from, err := echo.ReadFromUDP(buf)
-			if err != nil {
-				return
-			}
-			_, _ = echo.WriteToUDP(append(append([]byte{}, buf[:n]...), buf[:n]...), from)
-		}
-	}()
-	return echo.LocalAddr().(*net.UDPAddr)
-}
-
-func roundTrip(t *testing.T, relay *udpRelay, payload []byte) int {
-	t.Helper()
-	c, err := net.DialUDP("udp", nil, relay.LocalAddr().(*net.UDPAddr))
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer c.Close()
-	if _, err := c.Write(payload); err != nil {
-		t.Fatal(err)
-	}
-	_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
-	buf := make([]byte, 65535)
-	n, err := c.Read(buf)
-	if err != nil {
-		t.Fatalf("no reply through the relay: %v", err)
-	}
-	return n
-}
-
-func collectUntil(t *testing.T, relay *udpRelay, wantUp, wantDown int64) (int64, int64) {
-	t.Helper()
-	var up, down int64
-	deadline := time.Now().Add(2 * time.Second)
-	for {
-		u, d := relay.CollectTraffic()
-		up, down = up+u, down+d
-		if (up >= wantUp && down >= wantDown) || time.Now().After(deadline) {
-			return up, down
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-}
-
-func TestUDPRelayMetersBothDirectionsPerClient(t *testing.T) {
-	relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
-	if err != nil {
-		t.Fatal(err)
-	}
-	t.Cleanup(relay.Close)
-
-	if got := roundTrip(t, relay, bytes.Repeat([]byte("a"), 100)); got != 200 {
-		t.Fatalf("client A reply = %d bytes, want 200", got)
-	}
-	if got := roundTrip(t, relay, bytes.Repeat([]byte("b"), 50)); got != 100 {
-		t.Fatalf("client B reply = %d bytes, want 100", got)
-	}
-	if up, down := collectUntil(t, relay, 150, 300); up != 150 || down != 300 {
-		t.Fatalf("delta = (%d up, %d down), want (150, 300)", up, down)
-	}
-	if up, down := relay.CollectTraffic(); up != 0 || down != 0 {
-		t.Fatalf("second collect = (%d, %d), want (0, 0): deltas must reset", up, down)
-	}
-}
-
-func TestUDPRelayExpiresIdleFlows(t *testing.T) {
-	relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), 50*time.Millisecond)
-	if err != nil {
-		t.Fatal(err)
-	}
-	t.Cleanup(relay.Close)
-
-	roundTrip(t, relay, []byte("hello"))
-	deadline := time.Now().Add(2 * time.Second)
-	for {
-		relay.mu.Lock()
-		n := len(relay.flows)
-		relay.mu.Unlock()
-		if n == 0 {
-			break
-		}
-		if time.Now().After(deadline) {
-			t.Fatalf("%d flow(s) still open after the idle window", n)
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-	if got := roundTrip(t, relay, []byte("again")); got != 10 {
-		t.Fatalf("reply after expiry = %d bytes, want 10", got)
-	}
-}
-
-func TestUDPRelayRefusesFlowsAfterClose(t *testing.T) {
-	relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
-	if err != nil {
-		t.Fatal(err)
-	}
-	relay.Close()
-	if _, err := relay.flowFor(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9}); err == nil {
-		t.Fatal("flowFor after Close must refuse: its pump would outlive the relay and hang Close's WaitGroup")
-	}
-	relay.mu.Lock()
-	n := len(relay.flows)
-	relay.mu.Unlock()
-	if n != 0 {
-		t.Fatalf("%d flow(s) registered after Close", n)
-	}
-}
-
-func TestUDPRelayFullTableAdmitsNewClient(t *testing.T) {
-	relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
-	if err != nil {
-		t.Fatal(err)
-	}
-	t.Cleanup(relay.Close)
-	relay.mu.Lock()
-	relay.maxFlows = 2
-	relay.mu.Unlock()
-
-	roundTrip(t, relay, []byte("a"))
-	roundTrip(t, relay, []byte("b"))
-	if got := roundTrip(t, relay, []byte("c")); got != 2 {
-		t.Fatalf("third client reply = %d bytes, want 2: a full table must evict, not refuse", got)
-	}
-	relay.mu.Lock()
-	n := len(relay.flows)
-	relay.mu.Unlock()
-	if n != 2 {
-		t.Fatalf("flow table holds %d flows after admitting a third client, want the cap of 2", n)
-	}
-}

+ 47 - 0
internal/tuic/revocation_snapshot_test.go

@@ -0,0 +1,47 @@
+package tuic
+
+import (
+	"testing"
+	"time"
+)
+
+func TestAudit3ActualSessionRevokedAndSnapshotDrain(t *testing.T) {
+	for _, operation := range []string{"remove", "password", "email", "uuid"} {
+		t.Run(operation, func(t *testing.T) {
+			server, conn, id, password := startLifecycleTestServer(t, "127.0.0.1:1", "old@audit3")
+			_, user := authenticatedServerConnection(t, server, id)
+			user.Traffic.BytesUp.Add(123)
+			user.Traffic.BytesDown.Add(456)
+			client := TuicClientSettings{UUID: id.String(), Password: password, Email: "old@audit3"}
+			switch operation {
+			case "password":
+				client.Password = "rotated"
+			case "email":
+				client.Email = "new@audit3"
+			case "uuid":
+				client.UUID = "10000000-0000-0000-0000-000000000001"
+			}
+			if operation == "remove" {
+				server.UpdateUsers(nil)
+			} else {
+				server.UpdateUsers([]TuicClientSettings{client})
+			}
+			select {
+			case <-conn.Context().Done():
+			case <-time.After(time.Second):
+				t.Fatal("revoked QUIC remains connected")
+			}
+			deadline := time.Now().Add(time.Second)
+			for user.sessions.Load() > 0 && time.Now().Before(deadline) {
+				time.Sleep(time.Millisecond)
+			}
+			deltas := server.CollectClientTraffic()
+			if len(deltas) != 1 || deltas[0].Email != "old@audit3" || deltas[0].UUID != id.String() || deltas[0].Up != 123 || deltas[0].Down != 456 {
+				t.Fatalf("retired snapshot=%+v", deltas)
+			}
+			if again := server.CollectClientTraffic(); len(again) != 0 {
+				t.Fatalf("double drain=%+v", again)
+			}
+		})
+	}
+}

+ 1069 - 0
internal/tuic/server.go

@@ -0,0 +1,1069 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/apernet/quic-go"
+	xraycongestion "github.com/xtls/xray-core/transport/internet/hysteria/congestion"
+	"github.com/xtls/xray-core/transport/internet/hysteria/congestion/bbr"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// Server is an in-process native Go TUIC v5 server terminating QUIC
+// and bridging decrypted TCP/UDP into a local SOCKS5 inbound.
+type Server struct {
+	id                int
+	tag               atomic.Pointer[string]
+	listenAddr        string
+	authTimeout       time.Duration
+	congestionControl atomic.Value
+	logLevel          atomic.Uint32
+
+	maxUdpRelayPacketSize int
+
+	users *UserRegistry
+	relay *SocksRelay
+
+	tlsConfig    *tls.Config
+	quicConfig   *quic.Config
+	quicListener *quic.Listener
+	packetConn   net.PacketConn
+
+	lastOnline  sync.Map // email string -> time.Time
+	logThrottle sync.Map // event name -> *atomic.Int64 timestamp
+
+	activeConnsMu sync.Mutex
+	activeConns   map[[16]byte]map[*quic.Conn]*User
+	connectionsMu sync.Mutex
+	connections   map[*quic.Conn]struct{}
+
+	ctx    context.Context
+	cancel context.CancelFunc
+	wg     sync.WaitGroup
+
+	closed  atomic.Bool
+	running atomic.Bool
+}
+
+// NewServer creates a new TUIC v5 Server instance.
+func NewServer(inst Instance, relay *SocksRelay) (*Server, error) {
+	if err := ValidateClients(inst.Clients); err != nil {
+		return nil, err
+	}
+	if inst.Certificate == "" || inst.PrivateKey == "" {
+		return nil, errors.New("tuic: certificate or private key missing")
+	}
+
+	tlsCert, err := loadCertificate(inst.Certificate, inst.PrivateKey)
+	if err != nil {
+		return nil, fmt.Errorf("tuic: load tls certificate: %w", err)
+	}
+
+	alpn := inst.ALPN
+	if len(alpn) == 0 {
+		alpn = []string{"h3", "spdy/3.1"}
+	}
+
+	tlsConfig := &tls.Config{
+		Certificates: []tls.Certificate{tlsCert},
+		NextProtos:   alpn,
+	}
+
+	maxIdle := inst.MaxIdleTime
+	if maxIdle <= 0 {
+		maxIdle = 15
+	}
+	authTimeout := inst.AuthenticationTimeout
+	if authTimeout <= 0 {
+		authTimeout = 3
+	}
+	maxUdpSize := inst.MaxUdpRelayPacketSize
+	if maxUdpSize <= 0 {
+		maxUdpSize = 1500
+	}
+	if maxUdpSize > maxSafeUdpRelayPacketSize && maxUdpSize <= maxLegacyUdpRelayPacketSize {
+		maxUdpSize = maxSafeUdpRelayPacketSize
+	}
+	if maxUdpSize > maxLegacyUdpRelayPacketSize {
+		return nil, fmt.Errorf("tuic: max UDP relay packet size %d exceeds %d", maxUdpSize, maxSafeUdpRelayPacketSize)
+	}
+
+	quicConfig := &quic.Config{
+		EnableDatagrams: true,
+		MaxIdleTimeout:  time.Duration(maxIdle) * time.Second,
+		KeepAlivePeriod: time.Duration(maxIdle/2) * time.Second,
+		Allow0RTT:       inst.ZeroRTTHandshake,
+	}
+
+	registry := NewUserRegistry()
+	registry.SetUsers(inst.Clients)
+
+	ctx, cancel := context.WithCancel(context.Background())
+
+	s := &Server{
+		id:                    inst.Id,
+		listenAddr:            inst.BindTo(),
+		authTimeout:           time.Duration(authTimeout) * time.Second,
+		maxUdpRelayPacketSize: maxUdpSize,
+		users:                 registry,
+		activeConns:           make(map[[16]byte]map[*quic.Conn]*User),
+		connections:           make(map[*quic.Conn]struct{}),
+		relay:                 relay,
+		tlsConfig:             tlsConfig,
+		quicConfig:            quicConfig,
+		ctx:                   ctx,
+		cancel:                cancel,
+	}
+	s.updateRuntimeSettings(inst.Tag, inst.CongestionControl, inst.LogLevel)
+	return s, nil
+}
+
+// Start opens the UDP socket and starts the QUIC listener.
+func (s *Server) Start() error {
+	var lc net.ListenConfig
+	pConn, err := lc.ListenPacket(s.ctx, "udp", s.listenAddr)
+	if err != nil {
+		return fmt.Errorf("tuic: listen packet on %s: %w", s.listenAddr, err)
+	}
+	s.packetConn = pConn
+
+	ln, err := quic.Listen(pConn, s.tlsConfig, s.quicConfig)
+	if err != nil {
+		_ = pConn.Close()
+		return fmt.Errorf("tuic: quic listen on %s: %w", s.listenAddr, err)
+	}
+	s.quicListener = ln
+	s.running.Store(true)
+	s.logf(tuicLogInfo, "listener started on %s", s.listenAddr)
+
+	s.wg.Add(1)
+	go s.acceptLoop()
+
+	return nil
+}
+
+// IsRunning returns whether the server is currently accepting connections.
+func (s *Server) IsRunning() bool {
+	return s.running.Load() && !s.closed.Load()
+}
+
+func (s *Server) updateRuntimeSettings(tag, controller, logLevel string) {
+	tagCopy := tag
+	s.tag.Store(&tagCopy)
+	s.logLevel.Store(parseLogLevel(logLevel))
+	normalized, valid := normalizeCongestionControl(controller)
+	s.congestionControl.Store(normalized)
+	if !valid {
+		s.logf(tuicLogWarn, "unsupported congestion controller %q; using %s", controller, normalized)
+	}
+}
+
+func (s *Server) UpdateRuntimeSettings(tag, controller, logLevel string) {
+	s.updateRuntimeSettings(tag, controller, logLevel)
+}
+
+// applyCongestionControl installs Xray's BBR on an accepted connection; quic-go
+// itself only ships New Reno, so a CUBIC choice is served as New Reno.
+func (s *Server) applyCongestionControl(conn *quic.Conn) {
+	controller, _ := s.congestionControl.Load().(string)
+	switch controller {
+	case "bbr":
+		xraycongestion.UseBBR(conn, bbr.ProfileStandard)
+		s.logf(tuicLogDebug, "applied bbr congestion controller")
+	case "cubic":
+		s.logf(tuicLogDebug, "cubic is not available; applied new_reno congestion controller")
+	default:
+		s.logf(tuicLogDebug, "applied new_reno congestion controller")
+	}
+}
+
+func (s *Server) registerConn(user *User, conn *quic.Conn) {
+	s.activeConnsMu.Lock()
+	defer s.activeConnsMu.Unlock()
+	if s.activeConns[user.UUID] == nil {
+		s.activeConns[user.UUID] = make(map[*quic.Conn]*User)
+	}
+	s.activeConns[user.UUID][conn] = user
+	user.sessions.Add(1)
+}
+
+func (s *Server) unregisterConn(user *User, conn *quic.Conn) {
+	s.activeConnsMu.Lock()
+	if conns := s.activeConns[user.UUID]; conns != nil {
+		if registered, ok := conns[conn]; ok {
+			delete(conns, conn)
+			if registered == user {
+				s.users.sessionEnded(user)
+			}
+		}
+		if len(conns) == 0 {
+			delete(s.activeConns, user.UUID)
+		}
+	}
+	s.activeConnsMu.Unlock()
+}
+
+func (s *Server) closeUserConns(user *User) {
+	s.activeConnsMu.Lock()
+	conns := s.activeConns[user.UUID]
+	var toClose []*quic.Conn
+	for conn, registered := range conns {
+		if registered == user {
+			toClose = append(toClose, conn)
+		}
+	}
+	s.activeConnsMu.Unlock()
+
+	for _, conn := range toClose {
+		_ = conn.CloseWithError(0x100, "tuic: user revoked")
+	}
+}
+
+func (s *Server) closeAllConns() {
+	s.connectionsMu.Lock()
+	var all []*quic.Conn
+	for conn := range s.connections {
+		all = append(all, conn)
+	}
+	s.connectionsMu.Unlock()
+
+	for _, conn := range all {
+		_ = conn.CloseWithError(0x00, "tuic: server closed")
+	}
+}
+
+// UpdateUsers updates the active users dynamically without restarting the listener,
+// and terminates active QUIC sessions for any revoked or disabled users.
+func (s *Server) UpdateUsers(clients []TuicClientSettings) {
+	if err := ValidateClients(clients); err != nil {
+		s.logLimited(tuicLogWarn, "users-invalid", 30*time.Second, "User update rejected: %v", err)
+		return
+	}
+	revoked := s.users.SetUsers(clients)
+	if len(revoked) > 0 {
+		s.logf(tuicLogDebug, "Revoked %d user registrations", len(revoked))
+	}
+	for _, u := range revoked {
+		s.closeUserConns(u)
+	}
+}
+
+// GetActiveEmails returns emails that were active within the specified time window.
+func (s *Server) GetActiveEmails(window time.Duration) []string {
+	now := time.Now()
+	var active []string
+	s.lastOnline.Range(func(key, value any) bool {
+		email := key.(string)
+		lastTime := value.(time.Time)
+		if now.Sub(lastTime) <= window {
+			active = append(active, email)
+		}
+		return true
+	})
+	return active
+}
+
+// CollectClientTraffic drains and returns traffic deltas for each client.
+func (s *Server) CollectClientTraffic() []ClientTrafficDelta {
+	deltas := s.users.CollectTrafficDeltas()
+	for i := range deltas {
+		deltas[i].InboundID = s.id
+	}
+	return deltas
+}
+
+// CollectAllTraffic drains client deltas once and returns total up, down and individual client deltas.
+func (s *Server) CollectAllTraffic() (int64, int64, []ClientTrafficDelta) {
+	deltas := s.CollectClientTraffic()
+	var totalUp, totalDown int64
+	for _, d := range deltas {
+		totalUp += d.Up
+		totalDown += d.Down
+	}
+	return totalUp, totalDown, deltas
+}
+
+func (s *Server) markActive(email string) {
+	if email != "" {
+		s.lastOnline.Store(email, time.Now())
+	}
+}
+
+// AddTestTraffic adds byte counts to a client for testing purposes.
+func (s *Server) AddTestTraffic(email string, up, down int64) bool {
+	s.markActive(email)
+	return s.users.AddTestTraffic(email, up, down)
+}
+
+func (s *Server) acceptLoop() {
+	defer s.wg.Done()
+
+	for {
+		conn, err := s.quicListener.Accept(s.ctx)
+		if err != nil {
+			if s.closed.Load() {
+				return
+			}
+			s.running.Store(false)
+			s.logf(tuicLogError, "QUIC listener stopped accepting connections: %v", err)
+			_ = s.quicListener.Close()
+			return
+		}
+		s.connectionsMu.Lock()
+		if s.closed.Load() {
+			s.connectionsMu.Unlock()
+			_ = conn.CloseWithError(0x00, "tuic: server closed")
+			return
+		}
+		s.connections[conn] = struct{}{}
+		s.connectionsMu.Unlock()
+		s.wg.Add(1)
+		go func(c *quic.Conn) {
+			defer s.wg.Done()
+			s.handleConn(c)
+		}(conn)
+	}
+}
+
+func (s *Server) handleConn(conn *quic.Conn) {
+	defer func() {
+		s.connectionsMu.Lock()
+		delete(s.connections, conn)
+		s.connectionsMu.Unlock()
+	}()
+	s.applyCongestionControl(conn)
+	sessCtx, sessCancel := context.WithCancel(s.ctx)
+	stopConnWatch := context.AfterFunc(conn.Context(), sessCancel)
+	defer stopConnWatch()
+
+	var (
+		authUser        atomic.Pointer[User]
+		authState       atomic.Uint32 // 0 pending, 1 authenticated, 2 timed out
+		authSignal      = make(chan struct{})
+		authOnce        sync.Once
+		udpAssociations = newUdpAssociationRegistry(s.maxUdpRelayPacketSize)
+	)
+
+	authTimer := time.AfterFunc(s.authTimeout, func() {
+		if authState.CompareAndSwap(0, 2) {
+			s.logf(tuicLogWarn, "client authentication timed out")
+			sessCancel()
+			_ = conn.CloseWithError(0x100, "tuic: authentication timeout")
+		}
+	})
+	defer authTimer.Stop()
+
+	authenticate := func(rawUUID [16]byte, token [32]byte) (*User, error) {
+		tlsState := conn.ConnectionState().TLS
+		return s.users.AuthenticateAndRegister(&tlsState, rawUUID, token, func(user *User) bool {
+			if !authState.CompareAndSwap(0, 1) {
+				return authState.Load() == 1 && authUser.Load() == user
+			}
+			authUser.Store(user)
+			s.registerConn(user, conn)
+			s.markActive(user.Email)
+			authTimer.Stop()
+			s.logf(tuicLogInfo, "client authenticated")
+			authOnce.Do(func() { close(authSignal) })
+			return true
+		})
+	}
+
+	waitForAuth := func() (*User, error) {
+		if authState.Load() == 1 {
+			if u := authUser.Load(); u != nil {
+				return u, nil
+			}
+		}
+		if authState.Load() == 2 {
+			return nil, errors.New("tuic: authentication timeout")
+		}
+		select {
+		case <-authSignal:
+			if u := authUser.Load(); u != nil {
+				return u, nil
+			}
+			return nil, errors.New("tuic: authentication unavailable")
+		case <-sessCtx.Done():
+			return nil, sessCtx.Err()
+		}
+	}
+
+	var relayWg sync.WaitGroup
+	cleanup := func() {
+		sessCancel()
+		udpAssociations.closeAll()
+		relayWg.Wait()
+		if u := authUser.Load(); u != nil {
+			s.unregisterConn(u, conn)
+		}
+		_ = conn.CloseWithError(0, "")
+	}
+	defer cleanup()
+
+	var innerWg sync.WaitGroup
+	// Loop 1: Unidirectional streams
+	innerWg.Add(1)
+	go func() {
+		defer innerWg.Done()
+		for {
+			uniStream, err := conn.AcceptUniStream(sessCtx)
+			if err != nil {
+				return
+			}
+			innerWg.Add(1)
+			go func(stream *quic.ReceiveStream) {
+				defer innerWg.Done()
+				s.handleUniStream(sessCtx, conn, stream, authenticate, waitForAuth, udpAssociations, &relayWg)
+			}(uniStream)
+		}
+	}()
+
+	// Loop 2: Bidirectional streams
+	innerWg.Add(1)
+	go func() {
+		defer innerWg.Done()
+		for {
+			biStream, err := conn.AcceptStream(sessCtx)
+			if err != nil {
+				return
+			}
+			innerWg.Add(1)
+			go func(stream *quic.Stream) {
+				defer innerWg.Done()
+				s.handleBiStream(sessCtx, conn, stream, authenticate, waitForAuth)
+			}(biStream)
+		}
+	}()
+
+	// Loop 3: Datagrams
+	innerWg.Add(1)
+	go func() {
+		defer innerWg.Done()
+		for {
+			dgram, err := conn.ReceiveDatagram(sessCtx)
+			if err != nil {
+				return
+			}
+			s.handleDatagram(sessCtx, conn, dgram, waitForAuth, udpAssociations, &relayWg)
+		}
+	}()
+
+	innerWg.Add(1)
+	go func() {
+		defer innerWg.Done()
+		ticker := time.NewTicker(time.Minute)
+		defer ticker.Stop()
+		for {
+			select {
+			case <-ticker.C:
+				udpAssociations.reapIdle(time.Now())
+			case <-sessCtx.Done():
+				return
+			}
+		}
+	}()
+
+	innerWg.Wait()
+}
+
+func (s *Server) handleUniStream(
+	ctx context.Context,
+	conn *quic.Conn,
+	stream *quic.ReceiveStream,
+	authenticate func([16]byte, [32]byte) (*User, error),
+	waitForAuth func() (*User, error),
+	udpAssociations *udpAssociationRegistry,
+	relayWg *sync.WaitGroup,
+) {
+	defer stream.CancelRead(0)
+	_, cmd, err := ReadCommand(stream)
+	if err != nil {
+		return
+	}
+
+	switch cmd {
+	case CmdAuthenticate:
+		var authData [16 + 32]byte
+		if _, err := io.ReadFull(stream, authData[:]); err != nil {
+			return
+		}
+		var rawUUID [16]byte
+		var token [32]byte
+		copy(rawUUID[:], authData[0:16])
+		copy(token[:], authData[16:48])
+
+		_, err := authenticate(rawUUID, token)
+		if err != nil {
+			s.logLimited(tuicLogWarn, "auth-rejected", 30*time.Second, "client authentication rejected")
+			_ = conn.CloseWithError(0x100, "tuic: authentication failed")
+			return
+		}
+	case CmdDissociate:
+		if _, err := waitForAuth(); err != nil {
+			return
+		}
+		var assocIDBytes [2]byte
+		if _, err := io.ReadFull(stream, assocIDBytes[:]); err != nil {
+			return
+		}
+		assocID := binary.BigEndian.Uint16(assocIDBytes[:])
+		if udpAssociations.dissociate(assocID) {
+			s.logf(tuicLogInfo, "UDP association %d closed", assocID)
+		}
+
+	case CmdPacket:
+		user, err := waitForAuth()
+		if err != nil {
+			return
+		}
+		hdr, err := ReadPacketHeader(stream)
+		if err != nil || int(hdr.Size) > s.maxUdpRelayPacketSize {
+			s.logLimited(tuicLogWarn, "udp-malformed", 30*time.Second, "UDP packet rejected: malformed header or size limit")
+			return
+		}
+		payload, err := readPacketPayload(stream, hdr)
+		if err != nil {
+			return
+		}
+		s.handlePacket(ctx, conn, user, hdr, payload, packetTransportStream, udpAssociations, relayWg)
+	}
+}
+
+func (s *Server) handleBiStream(
+	ctx context.Context,
+	conn *quic.Conn,
+	stream *quic.Stream,
+	authenticate func([16]byte, [32]byte) (*User, error),
+	waitForAuth func() (*User, error),
+) {
+	defer stream.Close()
+	defer stream.CancelRead(0)
+
+	_, cmd, err := ReadCommand(stream)
+	if err != nil {
+		return
+	}
+
+	switch cmd {
+	case CmdAuthenticate:
+		var authData [16 + 32]byte
+		if _, err := io.ReadFull(stream, authData[:]); err != nil {
+			return
+		}
+		var rawUUID [16]byte
+		var token [32]byte
+		copy(rawUUID[:], authData[0:16])
+		copy(token[:], authData[16:48])
+
+		_, err := authenticate(rawUUID, token)
+		if err != nil {
+			s.logLimited(tuicLogWarn, "auth-rejected", 30*time.Second, "client authentication rejected")
+			_ = conn.CloseWithError(0x100, "tuic: authentication failed")
+			return
+		}
+	case CmdConnect:
+		user, err := waitForAuth()
+		if err != nil {
+			return
+		}
+		target, err := ReadAddress(stream)
+		if err != nil {
+			s.logLimited(tuicLogWarn, "tcp-relay", 30*time.Second, "TCP relay failed: malformed target address")
+			return
+		}
+
+		s.markActive(user.Email)
+		if !isPacketTarget(target) {
+			s.logLimited(tuicLogWarn, "tcp-relay", 30*time.Second, "TCP relay failed: invalid target address")
+			return
+		}
+		socksConn, err := s.relay.DialTCP(ctx, user.Email, target)
+		if err != nil {
+			s.logLimited(tuicLogWarn, "tcp-relay", 30*time.Second, "TCP relay failed: %v", err)
+			return
+		}
+		s.logf(tuicLogInfo, "TCP relay started")
+
+		PipeBiDirectionalContext(ctx, tcpRelayStream{stream}, socksConn, &user.Traffic.BytesUp, &user.Traffic.BytesDown)
+		s.logf(tuicLogDebug, "TCP relay closed")
+	}
+}
+
+type packetFragmentKey struct {
+	assocID   uint16
+	pktID     uint16
+	transport uint8
+}
+
+const (
+	packetTransportDatagram uint8 = iota
+	packetTransportStream
+)
+
+type udpRelaySession struct {
+	relay             *SocksUDPSession
+	responseTransport uint8
+}
+
+type packetReassembly struct {
+	total     uint8
+	received  uint8
+	size      int
+	frags     [][]byte
+	addr      *Address
+	updatedAt time.Time
+}
+
+type packetReassembler struct {
+	mu            sync.Mutex
+	maxPacketSize int
+	packets       map[packetFragmentKey]*packetReassembly
+}
+
+const (
+	maxSafeUdpRelayPacketSize   = maxSocksUdpDatagramSize - 262
+	maxLegacyUdpRelayPacketSize = maxSocksUdpDatagramSize
+	maxUdpRelayPacketSize       = maxSafeUdpRelayPacketSize
+	maxPendingPacketAssemblies  = 32
+	packetAssemblyTimeout       = 10 * time.Second
+)
+
+func newPacketReassembler(maxPacketSize int) *packetReassembler {
+	if maxPacketSize <= 0 || maxPacketSize > maxUdpRelayPacketSize {
+		maxPacketSize = maxUdpRelayPacketSize
+	}
+	return &packetReassembler{
+		maxPacketSize: maxPacketSize,
+		packets:       make(map[packetFragmentKey]*packetReassembly),
+	}
+}
+
+func (pr *packetReassembler) feed(transport uint8, hdr *PacketHeader, payload []byte) (*Address, []byte, bool) {
+	if hdr == nil || hdr.FragTotal == 0 || hdr.FragID >= hdr.FragTotal || int(hdr.Size) != len(payload) || len(payload) > pr.maxPacketSize {
+		return nil, nil, false
+	}
+	pr.mu.Lock()
+	defer pr.mu.Unlock()
+	now := time.Now()
+	pr.expireLocked(now)
+	key := packetFragmentKey{assocID: hdr.AssocID, pktID: hdr.PktID, transport: transport}
+	if hdr.FragTotal == 1 {
+		if hdr.FragID != 0 || !isPacketTarget(hdr.Addr) {
+			return nil, nil, false
+		}
+		delete(pr.packets, key)
+		return hdr.Addr, payload, true
+	}
+	if (hdr.FragID == 0 && !isPacketTarget(hdr.Addr)) || (hdr.FragID != 0 && hdr.Addr != nil && hdr.Addr.Type != AddrTypeNone) {
+		return nil, nil, false
+	}
+
+	entry, ok := pr.packets[key]
+	if !ok {
+		if len(pr.packets) >= maxPendingPacketAssemblies {
+			return nil, nil, false
+		}
+		entry = &packetReassembly{
+			total:     hdr.FragTotal,
+			frags:     make([][]byte, hdr.FragTotal),
+			updatedAt: now,
+		}
+		pr.packets[key] = entry
+	} else if entry.total != hdr.FragTotal {
+		delete(pr.packets, key)
+		return nil, nil, false
+	}
+
+	fragment := entry.frags[hdr.FragID]
+	if fragment != nil {
+		if !bytes.Equal(fragment, payload) {
+			delete(pr.packets, key)
+		}
+		return nil, nil, false
+	}
+	if entry.size+len(payload) > pr.maxPacketSize {
+		delete(pr.packets, key)
+		return nil, nil, false
+	}
+	entry.frags[hdr.FragID] = make([]byte, len(payload))
+	copy(entry.frags[hdr.FragID], payload)
+	entry.size += len(payload)
+	entry.received++
+	entry.updatedAt = now
+	if hdr.FragID == 0 {
+		entry.addr = hdr.Addr
+	}
+
+	if entry.received == entry.total {
+		delete(pr.packets, key)
+		if !isPacketTarget(entry.addr) {
+			return nil, nil, false
+		}
+		assembled := make([]byte, 0, entry.size)
+		for _, f := range entry.frags {
+			assembled = append(assembled, f...)
+		}
+		return entry.addr, assembled, true
+	}
+
+	return nil, nil, false
+}
+
+func (pr *packetReassembler) expireLocked(now time.Time) {
+	for key, entry := range pr.packets {
+		if now.Sub(entry.updatedAt) > packetAssemblyTimeout {
+			delete(pr.packets, key)
+		}
+	}
+}
+
+func (pr *packetReassembler) clearAssociation(assocID uint16) {
+	pr.mu.Lock()
+	defer pr.mu.Unlock()
+	for key := range pr.packets {
+		if key.assocID == assocID {
+			delete(pr.packets, key)
+		}
+	}
+}
+
+func (pr *packetReassembler) clearAll() {
+	pr.mu.Lock()
+	pr.packets = make(map[packetFragmentKey]*packetReassembly)
+	pr.mu.Unlock()
+}
+
+func isPacketTarget(addr *Address) bool {
+	return addr != nil && addr.Type != AddrTypeNone
+}
+
+func readPacketPayload(r io.Reader, hdr *PacketHeader) ([]byte, error) {
+	payload := make([]byte, int(hdr.Size))
+	if _, err := io.ReadFull(r, payload); err != nil {
+		return nil, err
+	}
+	return payload, nil
+}
+
+func (s *Server) handlePacket(
+	ctx context.Context,
+	conn *quic.Conn,
+	user *User,
+	hdr *PacketHeader,
+	payload []byte,
+	transport uint8,
+	udpAssociations *udpAssociationRegistry,
+	relayWg *sync.WaitGroup,
+) {
+	association, addr, fullPayload, complete := udpAssociations.feed(transport, hdr, payload)
+	if association == nil {
+		s.logLimited(tuicLogWarn, "udp-malformed", 30*time.Second, "UDP packet rejected")
+	}
+	if !complete {
+		return
+	}
+	s.forwardUDPPacket(ctx, conn, user, hdr.AssocID, association, addr, fullPayload, udpAssociations, relayWg)
+}
+
+func (s *Server) handleDatagram(
+	ctx context.Context,
+	conn *quic.Conn,
+	dgram []byte,
+	waitForAuth func() (*User, error),
+	udpAssociations *udpAssociationRegistry,
+	relayWg *sync.WaitGroup,
+) {
+	if len(dgram) < 2 || dgram[0] != ProtocolVersion {
+		return
+	}
+
+	cmd := dgram[1]
+	switch cmd {
+	case CmdHeartbeat:
+		if u, _ := waitForAuth(); u != nil {
+			s.markActive(u.Email)
+		}
+
+	case CmdPacket:
+		user, err := waitForAuth()
+		if err != nil {
+			return
+		}
+		r := bytes.NewReader(dgram[2:])
+		hdr, err := ReadPacketHeader(r)
+		if err != nil || int(hdr.Size) > s.maxUdpRelayPacketSize {
+			s.logLimited(tuicLogWarn, "udp-malformed", 30*time.Second, "UDP packet rejected: malformed header or size limit")
+			return
+		}
+		payload, err := readPacketPayload(r, hdr)
+		if err != nil || r.Len() != 0 {
+			return
+		}
+		s.handlePacket(ctx, conn, user, hdr, payload, packetTransportDatagram, udpAssociations, relayWg)
+
+	case CmdDissociate:
+		if len(dgram) >= 4 {
+			assocID := binary.BigEndian.Uint16(dgram[2:4])
+			if udpAssociations.dissociate(assocID) {
+				s.logf(tuicLogInfo, "UDP association %d closed", assocID)
+			}
+		}
+	}
+}
+
+func (s *Server) forwardUDPPacket(
+	ctx context.Context,
+	conn *quic.Conn,
+	user *User,
+	assocID uint16,
+	association *udpAssociation,
+	target *Address,
+	payload []byte,
+	udpAssociations *udpAssociationRegistry,
+	relayWg *sync.WaitGroup,
+) {
+	if len(payload) > s.maxUdpRelayPacketSize || !isPacketTarget(target) {
+		return
+	}
+	if association == nil || len(payload) > s.maxUdpRelayPacketSize || !isPacketTarget(target) {
+		return
+	}
+	association, created, err := udpAssociations.ensureRelay(ctx, assocID, association, user, s.relay)
+	if err != nil {
+		s.logLimited(tuicLogWarn, "udp-dial", 30*time.Second, "UDP relay could not be opened: %v", err)
+		return
+	}
+	sess := association.relay
+	if created {
+		s.logf(tuicLogInfo, "UDP association %d started", assocID)
+		relayWg.Add(1)
+		go func() {
+			defer relayWg.Done()
+			s.relayUDPResponses(ctx, conn, user, assocID, association, udpAssociations, sess)
+		}()
+	}
+
+	if _, err := sess.relay.Send(target, payload); err != nil {
+		s.logLimited(tuicLogWarn, "udp-send", 30*time.Second, "UDP relay request failed: %v", err)
+		return
+	}
+	user.Traffic.BytesUp.Add(int64(len(payload)))
+	s.markActive(user.Email)
+}
+
+const (
+	maxDatagramFragmentSize = 850
+	maxStreamFragmentSize   = 8 * 1024
+)
+
+func (s *Server) relayUDPResponses(
+	ctx context.Context,
+	conn *quic.Conn,
+	user *User,
+	assocID uint16,
+	association *udpAssociation,
+	associations *udpAssociationRegistry,
+	sess *udpRelaySession,
+) {
+	defer associations.release(assocID, association)
+	buf := make([]byte, s.maxUdpRelayPacketSize+263)
+	var nextPktID uint16
+	for {
+		srcAddr, respPayload, err := sess.relay.Receive(buf)
+		if err != nil {
+			if ctx.Err() == nil && !sess.relay.closed.Load() {
+				s.logLimited(tuicLogWarn, "udp-receive", 30*time.Second, "UDP relay receive failed: %v", err)
+			}
+			return
+		}
+		if len(respPayload) > s.maxUdpRelayPacketSize {
+			continue
+		}
+
+		user.Traffic.BytesDown.Add(int64(len(respPayload)))
+		s.markActive(user.Email)
+
+		nextPktID++
+		if err := s.sendUDPPacketFragments(ctx, conn, assocID, nextPktID, srcAddr, respPayload, sess.responseTransport); err != nil {
+			if ctx.Err() == nil {
+				s.logLimited(tuicLogWarn, "udp-response", 30*time.Second, "UDP relay response failed: %v", err)
+			}
+			return
+		}
+		associations.touch(assocID, association, time.Now())
+	}
+}
+
+func (s *Server) sendUDPPacketFragments(
+	ctx context.Context,
+	conn *quic.Conn,
+	assocID, pktID uint16,
+	srcAddr *Address,
+	payload []byte,
+	transport uint8,
+) error {
+	if len(payload) > s.maxUdpRelayPacketSize || !isPacketTarget(srcAddr) {
+		return fmt.Errorf("tuic: UDP response exceeds configured limit or has invalid source address")
+	}
+	fragmentSize := maxDatagramFragmentSize
+	if transport == packetTransportStream {
+		fragmentSize = maxStreamFragmentSize
+	}
+	fragmentTotal := (len(payload) + fragmentSize - 1) / fragmentSize
+	if fragmentTotal == 0 {
+		fragmentTotal = 1
+	}
+	if fragmentTotal > 255 {
+		return fmt.Errorf("tuic: UDP response requires too many fragments: %d", fragmentTotal)
+	}
+
+	for i := 0; i < fragmentTotal; i++ {
+		start := i * fragmentSize
+		end := min(start+fragmentSize, len(payload))
+		addr := (*Address)(nil)
+		if i == 0 {
+			addr = srcAddr
+		}
+		var frame bytes.Buffer
+		if err := WritePacket(&frame, assocID, pktID, uint8(fragmentTotal), uint8(i), addr, payload[start:end]); err != nil {
+			return err
+		}
+
+		if transport == packetTransportStream {
+			stream, err := conn.OpenUniStreamSync(ctx)
+			if err != nil {
+				return err
+			}
+			if _, err := stream.Write(frame.Bytes()); err != nil {
+				stream.CancelWrite(0)
+				return err
+			}
+			if err := stream.Close(); err != nil {
+				return err
+			}
+			continue
+		}
+		if err := conn.SendDatagram(frame.Bytes()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Close gracefully stops the server and releases all network resources.
+func (s *Server) Close() error {
+	if s.closed.Swap(true) {
+		return nil
+	}
+	s.running.Store(false)
+	s.logf(tuicLogInfo, "listener stopped")
+	s.cancel()
+
+	var err error
+	if s.quicListener != nil {
+		err = s.quicListener.Close()
+	}
+	if s.packetConn != nil {
+		_ = s.packetConn.Close()
+	}
+
+	s.closeAllConns()
+
+	s.wg.Wait()
+	return err
+}
+
+const (
+	tuicLogDebug uint32 = iota
+	tuicLogInfo
+	tuicLogWarn
+	tuicLogError
+)
+
+func parseLogLevel(level string) uint32 {
+	switch strings.ToLower(strings.TrimSpace(level)) {
+	case "debug":
+		return tuicLogDebug
+	case "warn", "warning":
+		return tuicLogWarn
+	case "error":
+		return tuicLogError
+	default:
+		return tuicLogInfo
+	}
+}
+
+func normalizeCongestionControl(controller string) (string, bool) {
+	normalized, err := NormalizeCongestionControl(controller)
+	if err != nil {
+		return "new_reno", false
+	}
+	return normalized, true
+}
+
+func (s *Server) logf(level uint32, format string, args ...any) {
+	if level < s.logLevel.Load() {
+		return
+	}
+	tag := ""
+	if value := s.tag.Load(); value != nil && *value != "" {
+		tag = fmt.Sprintf(" (%s)", *value)
+	}
+	message := fmt.Sprintf("tuic: inbound %d%s: %s", s.id, tag, fmt.Sprintf(format, args...))
+	switch level {
+	case tuicLogDebug:
+		logger.Debugf("%s", message)
+	case tuicLogInfo:
+		logger.Infof("%s", message)
+	case tuicLogWarn:
+		logger.Warningf("%s", message)
+	case tuicLogError:
+		logger.Errorf("%s", message)
+	}
+}
+
+func (s *Server) logLimited(level uint32, key string, interval time.Duration, format string, args ...any) {
+	if level < s.logLevel.Load() {
+		return
+	}
+	value, _ := s.logThrottle.LoadOrStore(key, &atomic.Int64{})
+	stamp := value.(*atomic.Int64)
+	now := time.Now().UnixNano()
+	last := stamp.Load()
+	if last != 0 && time.Duration(now-last) < interval {
+		return
+	}
+	if stamp.CompareAndSwap(last, now) {
+		s.logf(level, format, args...)
+	}
+}
+
+func loadCertificate(certInput, keyInput string) (tls.Certificate, error) {
+	if strings.Contains(certInput, "-----BEGIN CERTIFICATE-----") {
+		return tls.X509KeyPair([]byte(certInput), []byte(keyInput))
+	}
+	return tls.LoadX509KeyPair(certInput, keyInput)
+}
+
+// QUIC Close sends FIN but does not interrupt reads. Relay cancellation must
+// cancel reads too, while a normal EOF preserves the peer's half-close.
+type tcpRelayStream struct{ *quic.Stream }
+
+func (stream tcpRelayStream) Close() error {
+	stream.CancelRead(0)
+	return stream.Stream.Close()
+}
+func (stream tcpRelayStream) CloseWrite() error { return stream.Stream.Close() }

+ 129 - 0
internal/tuic/server_settings_test.go

@@ -0,0 +1,129 @@
+package tuic
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+func TestNormalizeCongestionControl(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		want      string
+		wantValid bool
+	}{
+		{name: "default", want: "bbr", wantValid: true},
+		{name: "bbr", input: "bbr", want: "bbr", wantValid: true},
+		{name: "cubic", input: "cubic", want: "cubic", wantValid: true},
+		{name: "new reno", input: "new_reno", want: "new_reno", wantValid: true},
+		{name: "reno alias", input: "reno", want: "new_reno", wantValid: true},
+		{name: "unknown falls back to reno", input: "quic", want: "new_reno"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got, valid := normalizeCongestionControl(test.input)
+			if got != test.want || valid != test.wantValid {
+				t.Fatalf("normalizeCongestionControl(%q) = (%q, %t), want (%q, %t)", test.input, got, valid, test.want, test.wantValid)
+			}
+		})
+	}
+}
+
+func TestLogfUsesCommonLoggerAndHonorsThreshold(t *testing.T) {
+	tests := []struct {
+		level string
+		want  []string
+	}{
+		{level: "debug", want: []string{"debug", "info", "warn", "error"}},
+		{level: "info", want: []string{"info", "warn", "error"}},
+		{level: "warn", want: []string{"warn", "error"}},
+		{level: "error", want: []string{"error"}},
+	}
+
+	for _, test := range tests {
+		t.Run(test.level, func(t *testing.T) {
+			marker := fmt.Sprintf("tuic-log-%s-%d", test.level, time.Now().UnixNano())
+			server := &Server{id: 99001}
+			server.updateRuntimeSettings("log-test", "bbr", test.level)
+			for _, level := range []struct {
+				name  string
+				value uint32
+			}{{"debug", tuicLogDebug}, {"info", tuicLogInfo}, {"warn", tuicLogWarn}, {"error", tuicLogError}} {
+				server.logf(level.value, "%s-%s", marker, level.name)
+			}
+
+			logs := strings.Join(logger.GetLogs(10000, "DEBUG"), "\n")
+			for _, name := range []string{"debug", "info", "warn", "error"} {
+				want := slices.Contains(test.want, name)
+				got := strings.Contains(logs, marker+"-"+name)
+				if got != want {
+					t.Errorf("log level %s present = %t, want %t", name, got, want)
+				}
+			}
+			if !strings.Contains(logs, "inbound 99001 (log-test)") {
+				t.Fatal("TUIC event was not written through the shared 3x-ui logger")
+			}
+		})
+	}
+}
+
+func TestLogLevelsAreIsolatedPerInboundAndUpdateLive(t *testing.T) {
+	marker := fmt.Sprintf("tuic-log-isolation-%d", time.Now().UnixNano())
+	debugInbound := &Server{id: 99011}
+	errorInbound := &Server{id: 99012}
+	debugInbound.updateRuntimeSettings("debug-inbound", "bbr", "debug")
+	errorInbound.updateRuntimeSettings("error-inbound", "bbr", "error")
+	debugInbound.logf(tuicLogInfo, "%s-debug", marker)
+	errorInbound.logf(tuicLogInfo, "%s-hidden", marker)
+	debugInbound.UpdateRuntimeSettings("debug-inbound", "bbr", "warn")
+	debugInbound.logf(tuicLogInfo, "%s-hidden-after-update", marker)
+	debugInbound.logf(tuicLogWarn, "%s-warn-after-update", marker)
+
+	logs := strings.Join(logger.GetLogs(10000, "DEBUG"), "\n")
+	if !strings.Contains(logs, marker+"-debug") || !strings.Contains(logs, marker+"-warn-after-update") {
+		t.Fatal("expected permitted events from debug inbound")
+	}
+	if strings.Contains(logs, marker+"-hidden") || strings.Contains(logs, marker+"-hidden-after-update") {
+		t.Fatal("a TUIC inbound emitted an event below its own log threshold")
+	}
+}
+
+func TestEnsureStartupFailureHonorsInboundLogThreshold(t *testing.T) {
+	tests := []struct {
+		level string
+		want  bool
+	}{
+		{level: "error", want: false},
+		{level: "warn", want: true},
+	}
+	for _, test := range tests {
+		t.Run(test.level, func(t *testing.T) {
+			marker := fmt.Sprintf("tuic-start-failure-%s-%d", test.level, time.Now().UnixNano())
+			manager := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}, pendingTraffic: map[string]ClientTrafficDelta{}}
+			err := manager.Ensure(Instance{
+				Id:                99031,
+				Tag:               marker,
+				Listen:            "127.0.0.1",
+				Port:              0,
+				LogLevel:          test.level,
+				CongestionControl: "bbr",
+				Clients:           []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000031", Password: "p", Email: "startup@x"}},
+			})
+			if err == nil {
+				t.Fatal("Ensure unexpectedly started without a certificate")
+			}
+
+			logs := strings.Join(logger.GetLogs(10000, "DEBUG"), "\n")
+			got := strings.Contains(logs, marker+"): failed to start server")
+			if got != test.want {
+				t.Fatalf("startup warning logged = %t, want %t", got, test.want)
+			}
+		})
+	}
+}

+ 758 - 0
internal/tuic/server_test.go

@@ -0,0 +1,758 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"io"
+	"math/big"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/quic-go/quic-go"
+)
+
+func generateTestCert(t *testing.T) (certPEM, keyPEM []byte) {
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatalf("failed to generate private key: %v", err)
+	}
+
+	template := x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			Organization: []string{"Test TUIC Server"},
+		},
+		NotBefore:             time.Now().Add(-1 * time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+		IPAddresses:           []net.IP{net.ParseIP("127.0.0.1")},
+		DNSNames:              []string{"localhost"},
+	}
+
+	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+	if err != nil {
+		t.Fatalf("failed to create certificate: %v", err)
+	}
+
+	certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+
+	privBytes, err := x509.MarshalECPrivateKey(priv)
+	if err != nil {
+		t.Fatalf("failed to marshal private key: %v", err)
+	}
+	keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
+
+	return certPEM, keyPEM
+}
+
+func TestServerTCPConnectE2E(t *testing.T) {
+	for _, controller := range []string{"bbr", "cubic", "new_reno"} {
+		t.Run(controller, func(t *testing.T) {
+			testServerTCPConnectE2E(t, controller)
+		})
+	}
+}
+
+func testServerTCPConnectE2E(t *testing.T, controller string) {
+	certPEM, keyPEM := generateTestCert(t)
+
+	// Start mock SOCKS5 server on loopback
+	socksAddr, socksCleanup := startMockSocks5Server(t, "[email protected]", "mock-socks-pass")
+	defer socksCleanup()
+
+	testUUID := uuid.New()
+	testPassword := "secret-client-password"
+
+	inst := Instance{
+		Id:                    1,
+		Tag:                   "tuic-test",
+		Listen:                "127.0.0.1",
+		Port:                  0,
+		Certificate:           string(certPEM),
+		PrivateKey:            string(keyPEM),
+		CongestionControl:     controller,
+		ALPN:                  []string{"h3"},
+		MaxIdleTime:           5,
+		AuthenticationTimeout: 2,
+		Clients: []TuicClientSettings{
+			{
+				UUID:     testUUID.String(),
+				Password: testPassword,
+				Email:    "[email protected]",
+			},
+		},
+	}
+
+	relay := &SocksRelay{
+		Addr:     socksAddr,
+		Password: "mock-socks-pass",
+	}
+
+	server, err := NewServer(inst, relay)
+	if err != nil {
+		t.Fatalf("NewServer failed: %v", err)
+	}
+
+	if err := server.Start(); err != nil {
+		t.Fatalf("Server.Start failed: %v", err)
+	}
+	defer server.Close()
+
+	serverAddr := server.packetConn.LocalAddr().String()
+
+	// Connect client to TUIC server via QUIC
+	clientTLS := &tls.Config{
+		InsecureSkipVerify: true,
+		NextProtos:         []string{"h3"},
+	}
+	quicConfig := &quic.Config{
+		EnableDatagrams: true,
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	conn, err := quic.DialAddr(ctx, serverAddr, clientTLS, quicConfig)
+	if err != nil {
+		t.Fatalf("quic.DialAddr failed: %v", err)
+	}
+	defer conn.CloseWithError(0, "")
+
+	// 1. Authenticate client on a uni stream
+	tlsState := conn.ConnectionState().TLS
+	token, err := tlsState.ExportKeyingMaterial(string(testUUID[:]), []byte(testPassword), 32)
+	if err != nil {
+		t.Fatalf("ExportKeyingMaterial failed: %v", err)
+	}
+
+	uniStream, err := conn.OpenUniStreamSync(ctx)
+	if err != nil {
+		t.Fatalf("OpenUniStreamSync failed: %v", err)
+	}
+	// Send: [VER (0x05)][0x00][UUID (16)][TOKEN (32)]
+	authPayload := make([]byte, 2+16+32)
+	authPayload[0] = ProtocolVersion
+	authPayload[1] = CmdAuthenticate
+	copy(authPayload[2:18], testUUID[:])
+	copy(authPayload[18:50], token)
+
+	if _, err := uniStream.Write(authPayload); err != nil {
+		t.Fatalf("write auth payload failed: %v", err)
+	}
+	_ = uniStream.Close()
+
+	// 2. Open bidirectional stream for TCP Connect
+	biStream, err := conn.OpenStreamSync(ctx)
+	if err != nil {
+		t.Fatalf("OpenStreamSync failed: %v", err)
+	}
+	defer biStream.Close()
+
+	// Send: [VER (0x05)][0x01][ADDR]
+	target := &Address{
+		Type: AddrTypeIPv4,
+		IP:   net.ParseIP("1.1.1.1"),
+		Port: 80,
+	}
+	var connectBuf bytes.Buffer
+	connectBuf.WriteByte(ProtocolVersion)
+	connectBuf.WriteByte(CmdConnect)
+	if err := WriteAddress(&connectBuf, target); err != nil {
+		t.Fatalf("WriteAddress failed: %v", err)
+	}
+	if _, err := biStream.Write(connectBuf.Bytes()); err != nil {
+		t.Fatalf("write connect cmd failed: %v", err)
+	}
+
+	// 3. Send test data and read echo response back through SOCKS5 bridge
+	testMsg := []byte("ping pong over native go tuic!")
+	if _, err := biStream.Write(testMsg); err != nil {
+		t.Fatalf("write test message failed: %v", err)
+	}
+
+	recvBuf := make([]byte, len(testMsg))
+	if _, err := io.ReadFull(biStream, recvBuf); err != nil {
+		t.Fatalf("read echo failed: %v", err)
+	}
+
+	if !bytes.Equal(recvBuf, testMsg) {
+		t.Fatalf("expected %q, got %q", testMsg, recvBuf)
+	}
+
+	// 4. Verify traffic was recorded for [email protected]
+	activeEmails := server.GetActiveEmails(10 * time.Second)
+	if len(activeEmails) == 0 || activeEmails[0] != "[email protected]" {
+		t.Fatalf("expected active email [email protected], got %v", activeEmails)
+	}
+
+	deltas := server.CollectClientTraffic()
+	if len(deltas) == 0 {
+		t.Fatalf("expected traffic deltas, got none")
+	}
+	if deltas[0].Email != "[email protected]" || deltas[0].Up < int64(len(testMsg)) || deltas[0].Down < int64(len(testMsg)) {
+		t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
+	}
+}
+
+func TestServerUDPDatagramE2E(t *testing.T) {
+	for _, controller := range []string{"bbr", "cubic", "new_reno"} {
+		t.Run(controller, func(t *testing.T) {
+			testServerUDPDatagramE2E(t, controller)
+		})
+	}
+}
+
+func testServerUDPDatagramE2E(t *testing.T, controller string) {
+	certPEM, keyPEM := generateTestCert(t)
+
+	socksAddr, socksCleanup := startMockSocks5Server(t, "[email protected]", "mock-socks-pass")
+	defer socksCleanup()
+
+	testUUID := uuid.New()
+	testPassword := "secret-bob-password"
+
+	inst := Instance{
+		Id:                    2,
+		Tag:                   "tuic-udp-test",
+		Listen:                "127.0.0.1",
+		Port:                  0,
+		Certificate:           string(certPEM),
+		PrivateKey:            string(keyPEM),
+		CongestionControl:     controller,
+		ALPN:                  []string{"h3"},
+		MaxIdleTime:           5,
+		AuthenticationTimeout: 2,
+		Clients: []TuicClientSettings{
+			{
+				UUID:     testUUID.String(),
+				Password: testPassword,
+				Email:    "[email protected]",
+			},
+		},
+	}
+
+	relay := &SocksRelay{
+		Addr:     socksAddr,
+		Password: "mock-socks-pass",
+	}
+
+	server, err := NewServer(inst, relay)
+	if err != nil {
+		t.Fatalf("NewServer failed: %v", err)
+	}
+
+	if err := server.Start(); err != nil {
+		t.Fatalf("Server.Start failed: %v", err)
+	}
+	defer server.Close()
+
+	serverAddr := server.packetConn.LocalAddr().String()
+
+	clientTLS := &tls.Config{
+		InsecureSkipVerify: true,
+		NextProtos:         []string{"h3"},
+	}
+	quicConfig := &quic.Config{
+		EnableDatagrams: true,
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	conn, err := quic.DialAddr(ctx, serverAddr, clientTLS, quicConfig)
+	if err != nil {
+		t.Fatalf("quic.DialAddr failed: %v", err)
+	}
+	defer conn.CloseWithError(0, "")
+
+	// 1. Authenticate via uni stream
+	tlsState := conn.ConnectionState().TLS
+	token, err := tlsState.ExportKeyingMaterial(string(testUUID[:]), []byte(testPassword), 32)
+	if err != nil {
+		t.Fatalf("ExportKeyingMaterial failed: %v", err)
+	}
+
+	uniStream, err := conn.OpenUniStreamSync(ctx)
+	if err != nil {
+		t.Fatalf("OpenUniStreamSync failed: %v", err)
+	}
+	authPayload := make([]byte, 2+16+32)
+	authPayload[0] = ProtocolVersion
+	authPayload[1] = CmdAuthenticate
+	copy(authPayload[2:18], testUUID[:])
+	copy(authPayload[18:50], token)
+	if _, err := uniStream.Write(authPayload); err != nil {
+		t.Fatalf("write auth payload failed: %v", err)
+	}
+	_ = uniStream.Close()
+
+	// 2. Send UDP datagram
+	target := &Address{
+		Type: AddrTypeIPv4,
+		IP:   net.ParseIP("8.8.8.8"),
+		Port: 53,
+	}
+	udpMsg := bytes.Repeat([]byte("d"), 1300)
+
+	// Give a tiny moment for auth to register
+	time.Sleep(50 * time.Millisecond)
+
+	fragmentTotal := (len(udpMsg) + maxDatagramFragmentSize - 1) / maxDatagramFragmentSize
+	for i := 0; i < fragmentTotal; i++ {
+		start := i * maxDatagramFragmentSize
+		end := min(start+maxDatagramFragmentSize, len(udpMsg))
+		addr := (*Address)(nil)
+		if i == 0 {
+			addr = target
+		}
+		var frame bytes.Buffer
+		if err := WritePacket(&frame, 100, 1, uint8(fragmentTotal), uint8(i), addr, udpMsg[start:end]); err != nil {
+			t.Fatalf("WritePacket failed: %v", err)
+		}
+		if err := conn.SendDatagram(frame.Bytes()); err != nil {
+			t.Fatalf("SendDatagram failed: %v", err)
+		}
+	}
+
+	// 3. Receive and reassemble the echo reply via datagrams.
+	replyReassembler := newPacketReassembler(1500)
+	var replyPayload []byte
+	for replyPayload == nil {
+		recvDgram, err := conn.ReceiveDatagram(ctx)
+		if err != nil {
+			t.Fatalf("ReceiveDatagram failed: %v", err)
+		}
+		if len(recvDgram) < 2 || recvDgram[0] != ProtocolVersion || recvDgram[1] != CmdPacket {
+			t.Fatalf("unexpected datagram reply: %x", recvDgram)
+		}
+		pktReader := bytes.NewReader(recvDgram[2:])
+		hdr, err := ReadPacketHeader(pktReader)
+		if err != nil {
+			t.Fatalf("ReadPacketHeader failed: %v", err)
+		}
+		fragment, err := readPacketPayload(pktReader, hdr)
+		if err != nil || pktReader.Len() != 0 {
+			t.Fatalf("read reply payload failed: %v", err)
+		}
+		_, assembled, complete := replyReassembler.feed(packetTransportDatagram, hdr, fragment)
+		if complete {
+			replyPayload = assembled
+		}
+	}
+
+	if !bytes.Equal(replyPayload, udpMsg) {
+		t.Fatalf("expected %q, got %q", udpMsg, replyPayload)
+	}
+
+	// 4. Verify traffic
+	deltas := server.CollectClientTraffic()
+	if len(deltas) == 0 {
+		t.Fatalf("expected traffic deltas, got none")
+	}
+	if deltas[0].Email != "[email protected]" || deltas[0].Up < int64(len(udpMsg)) || deltas[0].Down < int64(len(udpMsg)) {
+		t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
+	}
+}
+
+func TestServerUDPStreamE2E(t *testing.T) {
+	for _, controller := range []string{"bbr", "cubic", "new_reno"} {
+		t.Run(controller, func(t *testing.T) {
+			testServerUDPStreamE2E(t, controller)
+		})
+	}
+}
+
+func testServerUDPStreamE2E(t *testing.T, controller string) {
+	certPEM, keyPEM := generateTestCert(t)
+	socksAddr, socksCleanup := startMockSocks5Server(t, "[email protected]", "mock-socks-pass")
+	defer socksCleanup()
+
+	testUUID := uuid.New()
+	testPassword := "secret-stream-password"
+	server, err := NewServer(Instance{
+		Id:                    3,
+		Tag:                   "tuic-udp-stream-test",
+		Listen:                "127.0.0.1",
+		Certificate:           string(certPEM),
+		PrivateKey:            string(keyPEM),
+		ALPN:                  []string{"h3"},
+		MaxIdleTime:           5,
+		AuthenticationTimeout: 2,
+		MaxUdpRelayPacketSize: maxUdpRelayPacketSize,
+		CongestionControl:     controller,
+		Clients: []TuicClientSettings{{
+			UUID:     testUUID.String(),
+			Password: testPassword,
+			Email:    "[email protected]",
+		}},
+	}, &SocksRelay{Addr: socksAddr, Password: "mock-socks-pass"})
+	if err != nil {
+		t.Fatalf("NewServer failed: %v", err)
+	}
+	if err := server.Start(); err != nil {
+		t.Fatalf("Server.Start failed: %v", err)
+	}
+	defer server.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	conn, err := quic.DialAddr(ctx, server.packetConn.LocalAddr().String(), &tls.Config{
+		InsecureSkipVerify: true,
+		NextProtos:         []string{"h3"},
+	}, &quic.Config{EnableDatagrams: true})
+	if err != nil {
+		t.Fatalf("quic.DialAddr failed: %v", err)
+	}
+	defer conn.CloseWithError(0, "")
+
+	tlsState := conn.ConnectionState().TLS
+	token, err := tlsState.ExportKeyingMaterial(string(testUUID[:]), []byte(testPassword), 32)
+	if err != nil {
+		t.Fatalf("ExportKeyingMaterial failed: %v", err)
+	}
+	authStream, err := conn.OpenUniStreamSync(ctx)
+	if err != nil {
+		t.Fatalf("OpenUniStreamSync for authentication failed: %v", err)
+	}
+	authPayload := make([]byte, 2+16+32)
+	authPayload[0] = ProtocolVersion
+	authPayload[1] = CmdAuthenticate
+	copy(authPayload[2:18], testUUID[:])
+	copy(authPayload[18:], token)
+	if _, err := authStream.Write(authPayload); err != nil {
+		t.Fatalf("write authentication payload failed: %v", err)
+	}
+	if err := authStream.Close(); err != nil {
+		t.Fatalf("close authentication stream failed: %v", err)
+	}
+
+	target := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}
+	udpMsg := bytes.Repeat([]byte("s"), 8500)
+	fragmentTotal := (len(udpMsg) + maxStreamFragmentSize - 1) / maxStreamFragmentSize
+	for i := 0; i < fragmentTotal; i++ {
+		start := i * maxStreamFragmentSize
+		end := min(start+maxStreamFragmentSize, len(udpMsg))
+		addr := (*Address)(nil)
+		if i == 0 {
+			addr = target
+		}
+		var frame bytes.Buffer
+		if err := WritePacket(&frame, 300, 1, uint8(fragmentTotal), uint8(i), addr, udpMsg[start:end]); err != nil {
+			t.Fatalf("WritePacket failed: %v", err)
+		}
+		packetStream, err := conn.OpenUniStreamSync(ctx)
+		if err != nil {
+			t.Fatalf("OpenUniStreamSync for packet failed: %v", err)
+		}
+		if _, err := packetStream.Write(frame.Bytes()); err != nil {
+			t.Fatalf("write packet frame failed: %v", err)
+		}
+		if err := packetStream.Close(); err != nil {
+			t.Fatalf("close packet stream failed: %v", err)
+		}
+	}
+
+	replyReassembler := newPacketReassembler(maxUdpRelayPacketSize)
+	var reply []byte
+	for reply == nil {
+		responseStream, err := conn.AcceptUniStream(ctx)
+		if err != nil {
+			t.Fatalf("AcceptUniStream for response failed: %v", err)
+		}
+		_, command, err := ReadCommand(responseStream)
+		if err != nil {
+			t.Fatalf("read response command: %v", err)
+		}
+		if command != CmdPacket {
+			t.Fatalf("response command = %d, want %d", command, CmdPacket)
+		}
+		hdr, err := ReadPacketHeader(responseStream)
+		if err != nil {
+			t.Fatalf("ReadPacketHeader failed: %v", err)
+		}
+		fragment, err := readPacketPayload(responseStream, hdr)
+		if err != nil {
+			t.Fatalf("read response payload failed: %v", err)
+		}
+		_, assembled, complete := replyReassembler.feed(packetTransportStream, hdr, fragment)
+		if complete {
+			reply = assembled
+		}
+	}
+	if !bytes.Equal(reply, udpMsg) {
+		t.Fatalf("stream response size = %d, want %d", len(reply), len(udpMsg))
+	}
+}
+
+func TestNewServerRejectsOversizedMaxUdpRelayPacketSize(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+	_, err := NewServer(Instance{
+		Listen:                "127.0.0.1",
+		Port:                  8443,
+		Certificate:           string(certPEM),
+		PrivateKey:            string(keyPEM),
+		MaxUdpRelayPacketSize: maxLegacyUdpRelayPacketSize + 1,
+	}, &SocksRelay{Addr: "127.0.0.1:1"})
+	if err == nil {
+		t.Fatal("expected oversized max UDP relay packet size to be rejected")
+	}
+}
+
+func TestNewServerClampsLegacyUdpPayloadLimit(t *testing.T) {
+	certPEM, keyPEM := generateTestCert(t)
+	server, err := NewServer(Instance{
+		Listen:                "127.0.0.1",
+		Port:                  0,
+		Certificate:           string(certPEM),
+		PrivateKey:            string(keyPEM),
+		MaxUdpRelayPacketSize: maxLegacyUdpRelayPacketSize,
+	}, &SocksRelay{})
+	if err != nil {
+		t.Fatalf("NewServer: %v", err)
+	}
+	if server.maxUdpRelayPacketSize != maxSafeUdpRelayPacketSize {
+		t.Fatalf("legacy UDP limit = %d, want clamped limit %d", server.maxUdpRelayPacketSize, maxSafeUdpRelayPacketSize)
+	}
+}
+
+func TestPacketReassemblerInvalidatesAssemblyWhenFragmentTotalChanges(t *testing.T) {
+	reassembler := newPacketReassembler(64)
+	first := &PacketHeader{AssocID: 7, PktID: 9, FragTotal: 2, FragID: 0, Addr: &Address{Type: AddrTypeIPv4, IP: net.ParseIP("127.0.0.1"), Port: 53}, Size: 1}
+	if _, _, complete := reassembler.feed(packetTransportDatagram, first, []byte("A")); complete {
+		t.Fatal("first fragment unexpectedly completed")
+	}
+	single := &PacketHeader{AssocID: 7, PktID: 9, FragTotal: 1, FragID: 0, Addr: first.Addr, Size: 1}
+	if _, got, complete := reassembler.feed(packetTransportDatagram, single, []byte("Z")); !complete || string(got) != "Z" {
+		t.Fatalf("single packet = %q, complete=%v; want Z", got, complete)
+	}
+	last := &PacketHeader{AssocID: 7, PktID: 9, FragTotal: 2, FragID: 1, Size: 1}
+	if _, _, complete := reassembler.feed(packetTransportDatagram, last, []byte("B")); complete {
+		t.Fatal("stale first fragment was combined with a later packet")
+	}
+}
+
+func TestUdpAssociationPinsFirstPacketModeAndDissociateClearsFragments(t *testing.T) {
+	registry := newUdpAssociationRegistry(64)
+	addr := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("127.0.0.1"), Port: 53}
+	first := &PacketHeader{AssocID: 3, PktID: 1, FragTotal: 2, FragID: 0, Addr: addr, Size: 1}
+	association, _, _, complete := registry.feed(packetTransportDatagram, first, []byte("A"))
+	if association == nil || complete {
+		t.Fatal("expected first native fragment to establish an incomplete association")
+	}
+	singleStream := &PacketHeader{AssocID: 3, PktID: 2, FragTotal: 1, FragID: 0, Addr: addr, Size: 1}
+	association, _, _, complete = registry.feed(packetTransportStream, singleStream, []byte("S"))
+	if association.responseTransport != packetTransportDatagram || !complete {
+		t.Fatalf("mixed-mode packet changed response mode: association=%+v complete=%v", association, complete)
+	}
+
+	if !registry.dissociate(3) {
+		t.Fatal("expected dissociate to remove association")
+	}
+	late := &PacketHeader{AssocID: 3, PktID: 1, FragTotal: 2, FragID: 1, Size: 1}
+	_, _, _, complete = registry.feed(packetTransportDatagram, late, []byte("B"))
+	if complete {
+		t.Fatal("late fragment completed an assembly from before dissociate")
+	}
+}
+
+func TestPacketReassembler(t *testing.T) {
+	pr := newPacketReassembler(1500)
+	targetAddr := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 53}
+
+	// 1. Unfragmented packet
+	hdrSingle := &PacketHeader{
+		AssocID:   1,
+		PktID:     1,
+		FragTotal: 1,
+		FragID:    0,
+		Size:      uint16(len("hello single")),
+		Addr:      targetAddr,
+	}
+	addr, payload, complete := pr.feed(packetTransportDatagram, hdrSingle, []byte("hello single"))
+	if addr == nil || !complete || string(payload) != "hello single" {
+		t.Fatalf("unexpected single packet result: %v, %s", addr, payload)
+	}
+
+	// 2. In-order fragments (3 parts)
+	hdr0 := &PacketHeader{AssocID: 2, PktID: 10, FragTotal: 3, FragID: 0, Size: 6, Addr: targetAddr}
+	hdr1 := &PacketHeader{AssocID: 2, PktID: 10, FragTotal: 3, FragID: 1, Size: 6, Addr: &Address{Type: AddrTypeNone}}
+	hdr2 := &PacketHeader{AssocID: 2, PktID: 10, FragTotal: 3, FragID: 2, Size: 5, Addr: &Address{Type: AddrTypeNone}}
+
+	_, p0, complete := pr.feed(packetTransportDatagram, hdr0, []byte("part0-"))
+	if p0 != nil || complete {
+		t.Fatalf("expected nil before all fragments arrive, got %s", p0)
+	}
+	_, p1, complete := pr.feed(packetTransportDatagram, hdr1, []byte("part1-"))
+	if p1 != nil || complete {
+		t.Fatalf("expected nil before all fragments arrive, got %s", p1)
+	}
+	a2, p2, complete := pr.feed(packetTransportDatagram, hdr2, []byte("part2"))
+	if a2 == nil || !complete || string(p2) != "part0-part1-part2" {
+		t.Fatalf("expected reassembled payload 'part0-part1-part2', got %v, %s", a2, p2)
+	}
+
+	// 3. Out-of-order fragments (parts 1, 2, 0)
+	hdrOO0 := &PacketHeader{AssocID: 3, PktID: 20, FragTotal: 3, FragID: 0, Size: 6, Addr: targetAddr}
+	hdrOO1 := &PacketHeader{AssocID: 3, PktID: 20, FragTotal: 3, FragID: 1, Size: 7, Addr: &Address{Type: AddrTypeNone}}
+	hdrOO2 := &PacketHeader{AssocID: 3, PktID: 20, FragTotal: 3, FragID: 2, Size: 3, Addr: &Address{Type: AddrTypeNone}}
+
+	if _, p, done := pr.feed(packetTransportDatagram, hdrOO1, []byte("MIDDLE-")); p != nil || done {
+		t.Fatalf("expected nil, got %s", p)
+	}
+	if _, p, done := pr.feed(packetTransportDatagram, hdrOO2, []byte("END")); p != nil || done {
+		t.Fatalf("expected nil, got %s", p)
+	}
+	aOO, pOO, done := pr.feed(packetTransportDatagram, hdrOO0, []byte("START-"))
+	if aOO == nil || !done || string(pOO) != "START-MIDDLE-END" {
+		t.Fatalf("expected 'START-MIDDLE-END', got %s", pOO)
+	}
+
+	// 4. Invalid FragID >= FragTotal
+	hdrInv := &PacketHeader{AssocID: 4, PktID: 30, FragTotal: 2, FragID: 2, Size: 7, Addr: targetAddr}
+	if _, p, done := pr.feed(packetTransportDatagram, hdrInv, []byte("invalid")); p != nil || done {
+		t.Fatalf("expected nil for invalid FragID, got %s", p)
+	}
+
+	// A changed fragment total for an in-flight packet must discard the packet safely.
+	hdrMixed0 := &PacketHeader{AssocID: 5, PktID: 40, FragTotal: 2, FragID: 0, Size: 1, Addr: targetAddr}
+	hdrMixed3 := &PacketHeader{AssocID: 5, PktID: 40, FragTotal: 4, FragID: 3, Size: 1, Addr: &Address{Type: AddrTypeNone}}
+	if _, _, done := pr.feed(packetTransportDatagram, hdrMixed0, []byte("a")); done {
+		t.Fatal("expected first mixed-total fragment to remain incomplete")
+	}
+	if _, _, done := pr.feed(packetTransportDatagram, hdrMixed3, []byte("b")); done {
+		t.Fatal("expected inconsistent fragment total to be discarded")
+	}
+	if _, ok := pr.packets[packetFragmentKey{assocID: 5, pktID: 40, transport: packetTransportDatagram}]; ok {
+		t.Fatal("inconsistent packet assembly was not discarded")
+	}
+
+	// Fragments from different transports cannot be combined into one packet.
+	streamFirst := &PacketHeader{AssocID: 6, PktID: 50, FragTotal: 2, FragID: 0, Size: 1, Addr: targetAddr}
+	datagramLast := &PacketHeader{AssocID: 6, PktID: 50, FragTotal: 2, FragID: 1, Size: 1, Addr: &Address{Type: AddrTypeNone}}
+	if _, _, done := pr.feed(packetTransportStream, streamFirst, []byte("a")); done {
+		t.Fatal("expected first stream fragment to remain incomplete")
+	}
+	if _, _, done := pr.feed(packetTransportDatagram, datagramLast, []byte("b")); done {
+		t.Fatal("fragments from different transports must not combine")
+	}
+	if _, _, done := pr.feed(packetTransportStream, datagramLast, []byte("b")); !done {
+		t.Fatal("expected stream fragments to reassemble")
+	}
+
+	// The configured size limit caps both complete packets and reassembly state.
+	limited := newPacketReassembler(3)
+	tooLarge0 := &PacketHeader{AssocID: 7, PktID: 60, FragTotal: 2, FragID: 0, Size: 2, Addr: targetAddr}
+	tooLarge1 := &PacketHeader{AssocID: 7, PktID: 60, FragTotal: 2, FragID: 1, Size: 2, Addr: &Address{Type: AddrTypeNone}}
+	if _, _, done := limited.feed(packetTransportDatagram, tooLarge0, []byte("ab")); done {
+		t.Fatal("expected first oversized packet fragment to remain incomplete")
+	}
+	if _, _, done := limited.feed(packetTransportDatagram, tooLarge1, []byte("cd")); done {
+		t.Fatal("oversized reassembled packet must be rejected")
+	}
+	if len(limited.packets) != 0 {
+		t.Fatal("oversized reassembly state was not discarded")
+	}
+
+	bounded := newPacketReassembler(1500)
+	for i := 0; i < maxPendingPacketAssemblies; i++ {
+		hdr := &PacketHeader{
+			AssocID:   8,
+			PktID:     uint16(i),
+			FragTotal: 2,
+			FragID:    0,
+			Size:      1,
+			Addr:      targetAddr,
+		}
+		if _, _, done := bounded.feed(packetTransportDatagram, hdr, []byte("a")); done {
+			t.Fatal("expected pending fragment to remain incomplete")
+		}
+	}
+	if len(bounded.packets) != maxPendingPacketAssemblies {
+		t.Fatalf("pending assembly count = %d, want %d", len(bounded.packets), maxPendingPacketAssemblies)
+	}
+	extra := &PacketHeader{AssocID: 8, PktID: 100, FragTotal: 2, FragID: 0, Size: 1, Addr: targetAddr}
+	if _, _, done := bounded.feed(packetTransportDatagram, extra, []byte("a")); done {
+		t.Fatal("expected new assembly to be rejected when the pending limit is reached")
+	}
+	if len(bounded.packets) != maxPendingPacketAssemblies {
+		t.Fatalf("pending assembly count after overflow = %d, want %d", len(bounded.packets), maxPendingPacketAssemblies)
+	}
+
+	for _, packet := range bounded.packets {
+		packet.updatedAt = time.Now().Add(-packetAssemblyTimeout - time.Second)
+	}
+	if _, _, done := bounded.feed(packetTransportDatagram, extra, []byte("a")); done {
+		t.Fatal("expected new fragment to remain incomplete after stale entries are evicted")
+	}
+	if len(bounded.packets) != 1 {
+		t.Fatalf("pending assembly count after stale cleanup = %d, want 1", len(bounded.packets))
+	}
+}
+
+func TestAuthenticationTimeoutClosesUnauthenticatedConnections(t *testing.T) {
+	for _, partial := range []bool{false, true} {
+		name := "no-authenticate"
+		if partial {
+			name = "partial-authenticate"
+		}
+		t.Run(name, func(t *testing.T) {
+			certPEM, keyPEM := generateTestCert(t)
+			server, err := NewServer(Instance{
+				Id:                    99010,
+				Tag:                   "auth-timeout-test",
+				Listen:                "127.0.0.1",
+				Port:                  0,
+				Certificate:           string(certPEM),
+				PrivateKey:            string(keyPEM),
+				ALPN:                  []string{"h3"},
+				MaxIdleTime:           5,
+				AuthenticationTimeout: 1,
+			}, &SocksRelay{})
+			if err != nil {
+				t.Fatalf("NewServer: %v", err)
+			}
+			if err := server.Start(); err != nil {
+				t.Fatalf("Server.Start: %v", err)
+			}
+			t.Cleanup(func() { _ = server.Close() })
+
+			ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+			defer cancel()
+			conn, err := quic.DialAddr(ctx, server.packetConn.LocalAddr().String(), &tls.Config{
+				InsecureSkipVerify: true,
+				NextProtos:         []string{"h3"},
+			}, &quic.Config{EnableDatagrams: true, KeepAlivePeriod: time.Second})
+			if err != nil {
+				t.Fatalf("quic.DialAddr: %v", err)
+			}
+			defer conn.CloseWithError(0, "")
+
+			if partial {
+				stream, err := conn.OpenUniStreamSync(ctx)
+				if err != nil {
+					t.Fatalf("OpenUniStreamSync: %v", err)
+				}
+				if _, err := stream.Write([]byte{ProtocolVersion, CmdAuthenticate, 1}); err != nil {
+					t.Fatalf("write partial Authenticate: %v", err)
+				}
+			}
+
+			select {
+			case <-conn.Context().Done():
+			case <-ctx.Done():
+				t.Fatalf("server left unauthenticated QUIC connection open: %v", ctx.Err())
+			}
+		})
+	}
+}

+ 578 - 0
internal/tuic/socks_bridge.go

@@ -0,0 +1,578 @@
+package tuic
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/binary"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+var ErrUdpPayloadTooLarge = errors.New("tuic socks: UDP packet exceeds the maximum SOCKS datagram size")
+
+const maxSocksUdpDatagramSize = 65507
+
+// SocksRelay describes the loopback SOCKS5 endpoint where decrypted TUIC traffic is forwarded.
+type SocksRelay struct {
+	Addr     string // e.g. "127.0.0.1:63201"
+	Password string // internal shared password for the SOCKS inbound
+}
+
+// CountingConn wraps a net.Conn and tracks bytes read and written atomically.
+type CountingConn struct {
+	net.Conn
+	bytesRead    *atomic.Int64
+	bytesWritten *atomic.Int64
+}
+
+func (c *CountingConn) Read(p []byte) (int, error) {
+	n, err := c.Conn.Read(p)
+	if n > 0 && c.bytesRead != nil {
+		c.bytesRead.Add(int64(n))
+	}
+	return n, err
+}
+
+func (c *CountingConn) Write(p []byte) (int, error) {
+	n, err := c.Conn.Write(p)
+	if n > 0 && c.bytesWritten != nil {
+		c.bytesWritten.Add(int64(n))
+	}
+	return n, err
+}
+
+// DialTCP establishes a SOCKS5 CONNECT tunnel to the target address on behalf of user.
+func (r *SocksRelay) DialTCP(ctx context.Context, user string, target *Address) (net.Conn, error) {
+	dialer := net.Dialer{Timeout: 10 * time.Second}
+	conn, err := dialer.DialContext(ctx, "tcp", r.Addr)
+	if err != nil {
+		return nil, fmt.Errorf("tuic socks: dial relay %s: %w", r.Addr, err)
+	}
+
+	_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
+
+	if err := socks5Handshake(conn, user, r.Password); err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	// Send SOCKS5 CONNECT request
+	req := buildSocks5ConnectRequest(target)
+	if req == nil {
+		conn.Close()
+		return nil, ErrInvalidAddr
+	}
+	if _, err := conn.Write(req); err != nil {
+		conn.Close()
+		return nil, fmt.Errorf("tuic socks: send CONNECT request: %w", err)
+	}
+
+	if _, err := readSocks5Reply(conn); err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	_ = conn.SetDeadline(time.Time{})
+
+	return conn, nil
+}
+
+func buildSocks5ConnectRequest(target *Address) []byte {
+	if target == nil {
+		return nil
+	}
+	var req []byte
+	switch target.Type {
+	case AddrTypeIPv4:
+		ip4 := target.IP.To4()
+		if len(ip4) != 4 {
+			return nil
+		}
+		req = make([]byte, 4+4+2)
+		req[0] = 0x05 // SOCKS5
+		req[1] = 0x01 // CONNECT
+		req[2] = 0x00 // RSV
+		req[3] = 0x01 // ATYP IPv4
+		copy(req[4:8], ip4)
+		binary.BigEndian.PutUint16(req[8:10], target.Port)
+
+	case AddrTypeIPv6:
+		ip16 := target.IP.To16()
+		if len(ip16) != 16 {
+			return nil
+		}
+		req = make([]byte, 4+16+2)
+		req[0] = 0x05
+		req[1] = 0x01
+		req[2] = 0x00
+		req[3] = 0x04 // ATYP IPv6
+		copy(req[4:20], ip16)
+		binary.BigEndian.PutUint16(req[20:22], target.Port)
+
+	case AddrTypeDomain:
+		dLen := len(target.Host)
+		if dLen == 0 || dLen > 255 {
+			return nil
+		}
+		req = make([]byte, 4+1+dLen+2)
+		req[0] = 0x05
+		req[1] = 0x01
+		req[2] = 0x00
+		req[3] = 0x03 // ATYP Domain
+		req[4] = byte(dLen)
+		copy(req[5:5+dLen], []byte(target.Host))
+		binary.BigEndian.PutUint16(req[5+dLen:7+dLen], target.Port)
+
+	default:
+		return nil
+	}
+	return req
+}
+
+// SocksUDPSession manages a SOCKS5 UDP ASSOCIATE tunnel to Xray.
+type SocksUDPSession struct {
+	ctrl     net.Conn
+	udpConn  *net.UDPConn
+	targetEP net.Addr
+	user     string
+	closed   atomic.Bool
+}
+
+// DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray.
+func (r *SocksRelay) DialUDP(ctx context.Context, user string) (*SocksUDPSession, error) {
+	dialer := net.Dialer{Timeout: 10 * time.Second}
+	ctrl, err := dialer.DialContext(ctx, "tcp", r.Addr)
+	if err != nil {
+		return nil, fmt.Errorf("tuic socks: dial UDP control connection: %w", err)
+	}
+
+	_ = ctrl.SetDeadline(time.Now().Add(10 * time.Second))
+
+	if err := socks5Handshake(ctrl, user, r.Password); err != nil {
+		ctrl.Close()
+		return nil, err
+	}
+
+	// SOCKS5 UDP ASSOCIATE (0x03), dst 0.0.0.0:0
+	if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
+		ctrl.Close()
+		return nil, fmt.Errorf("tuic socks: send UDP ASSOCIATE request: %w", err)
+	}
+
+	bind, err := readSocks5Reply(ctrl)
+	if err != nil {
+		ctrl.Close()
+		return nil, err
+	}
+
+	_ = ctrl.SetDeadline(time.Time{})
+
+	udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
+	if err != nil {
+		ctrl.Close()
+		return nil, fmt.Errorf("tuic socks: dial UDP relay endpoint %s: %w", bind, err)
+	}
+
+	return &SocksUDPSession{
+		ctrl:     ctrl,
+		udpConn:  udpConn,
+		targetEP: udpConn.RemoteAddr(),
+		user:     user,
+	}, nil
+}
+
+// Send sends a UDP payload to target via the SOCKS5 UDP ASSOCIATE relay.
+func (s *SocksUDPSession) Send(target *Address, payload []byte) (int, error) {
+	if s.closed.Load() {
+		return 0, net.ErrClosed
+	}
+
+	packet, err := buildSocks5UDPRequest(target, payload)
+	if err != nil {
+		return 0, err
+	}
+
+	return s.udpConn.Write(packet)
+}
+
+func buildSocks5UDPRequest(target *Address, payload []byte) ([]byte, error) {
+	hdr := buildSocks5UDPHeader(target)
+	if hdr == nil {
+		return nil, ErrInvalidAddr
+	}
+	if len(hdr)+len(payload) > maxSocksUdpDatagramSize {
+		return nil, ErrUdpPayloadTooLarge
+	}
+	packet := make([]byte, len(hdr)+len(payload))
+	copy(packet, hdr)
+	copy(packet[len(hdr):], payload)
+	return packet, nil
+}
+
+// Receive reads a relayed UDP payload and extracts its original source address.
+func (s *SocksUDPSession) Receive(buf []byte) (*Address, []byte, error) {
+	if s.closed.Load() {
+		return nil, nil, net.ErrClosed
+	}
+
+	n, err := s.udpConn.Read(buf)
+	if err != nil {
+		return nil, nil, err
+	}
+	if n < 4 {
+		return nil, nil, fmt.Errorf("tuic socks: UDP packet too short (%d bytes)", n)
+	}
+
+	// SOCKS5 UDP header: [RSV(2)][FRAG(1)][ATYP(1)]
+	atyp := buf[3]
+	var addr *Address
+	var offset int
+
+	switch atyp {
+	case 0x01: // IPv4
+		if n < 10 {
+			return nil, nil, fmt.Errorf("tuic socks: truncated IPv4 UDP reply")
+		}
+		ip := net.IP(buf[4:8])
+		port := binary.BigEndian.Uint16(buf[8:10])
+		addr = &Address{Type: AddrTypeIPv4, IP: ip, Host: ip.String(), Port: port}
+		offset = 10
+
+	case 0x04: // IPv6
+		if n < 22 {
+			return nil, nil, fmt.Errorf("tuic socks: truncated IPv6 UDP reply")
+		}
+		ip := net.IP(buf[4:20])
+		port := binary.BigEndian.Uint16(buf[20:22])
+		addr = &Address{Type: AddrTypeIPv6, IP: ip, Host: ip.String(), Port: port}
+		offset = 22
+
+	case 0x03: // Domain
+		dLen := int(buf[4])
+		if n < 5+dLen+2 {
+			return nil, nil, fmt.Errorf("tuic socks: truncated domain UDP reply")
+		}
+		host := string(buf[5 : 5+dLen])
+		port := binary.BigEndian.Uint16(buf[5+dLen : 7+dLen])
+		addr = &Address{Type: AddrTypeDomain, Host: host, Port: port}
+		offset = 7 + dLen
+
+	default:
+		return nil, nil, fmt.Errorf("tuic socks: unsupported reply ATYP 0x%02x", atyp)
+	}
+
+	return addr, buf[offset:n], nil
+}
+
+// Close closes the SOCKS5 UDP session.
+func (s *SocksUDPSession) Close() error {
+	if s.closed.Swap(true) {
+		return nil
+	}
+	_ = s.udpConn.Close()
+	return s.ctrl.Close()
+}
+
+func buildSocks5UDPHeader(target *Address) []byte {
+	if target == nil {
+		return nil
+	}
+	var hdr []byte
+	switch target.Type {
+	case AddrTypeIPv4:
+		ip4 := target.IP.To4()
+		if len(ip4) != 4 {
+			return nil
+		}
+		hdr = make([]byte, 10)
+		hdr[0] = 0x00 // RSV
+		hdr[1] = 0x00 // RSV
+		hdr[2] = 0x00 // FRAG
+		hdr[3] = 0x01 // ATYP IPv4
+		copy(hdr[4:8], ip4)
+		binary.BigEndian.PutUint16(hdr[8:10], target.Port)
+
+	case AddrTypeIPv6:
+		ip16 := target.IP.To16()
+		if len(ip16) != 16 {
+			return nil
+		}
+		hdr = make([]byte, 22)
+		hdr[0] = 0x00
+		hdr[1] = 0x00
+		hdr[2] = 0x00
+		hdr[3] = 0x04 // ATYP IPv6
+		copy(hdr[4:20], ip16)
+		binary.BigEndian.PutUint16(hdr[20:22], target.Port)
+
+	case AddrTypeDomain:
+		dLen := len(target.Host)
+		if dLen == 0 || dLen > 255 {
+			return nil
+		}
+		hdr = make([]byte, 4+1+dLen+2)
+		hdr[0] = 0x00
+		hdr[1] = 0x00
+		hdr[2] = 0x00
+		hdr[3] = 0x03 // ATYP Domain
+		hdr[4] = byte(dLen)
+		copy(hdr[5:5+dLen], []byte(target.Host))
+		binary.BigEndian.PutUint16(hdr[5+dLen:7+dLen], target.Port)
+
+	default:
+		return nil
+	}
+	return hdr
+}
+
+func socks5Handshake(conn net.Conn, user, password string) error {
+	if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
+		return fmt.Errorf("tuic socks: send greeting: %w", err)
+	}
+	var resp [2]byte
+	if _, err := io.ReadFull(conn, resp[:]); err != nil {
+		return fmt.Errorf("tuic socks: read greeting reply: %w", err)
+	}
+	if resp[0] != 0x05 {
+		return fmt.Errorf("tuic socks: unexpected SOCKS version %d", resp[0])
+	}
+	switch resp[1] {
+	case 0x00: // no auth
+		return nil
+	case 0x02: // username/password
+		req := make([]byte, 0, 3+len(user)+len(password))
+		req = append(req, 0x01, byte(len(user)))
+		req = append(req, user...)
+		req = append(req, byte(len(password)))
+		req = append(req, password...)
+		if _, err := conn.Write(req); err != nil {
+			return fmt.Errorf("tuic socks: send auth: %w", err)
+		}
+		var authResp [2]byte
+		if _, err := io.ReadFull(conn, authResp[:]); err != nil {
+			return fmt.Errorf("tuic socks: read auth reply: %w", err)
+		}
+		if authResp[1] != 0x00 {
+			return fmt.Errorf("tuic socks: auth rejected (code %d)", authResp[1])
+		}
+		return nil
+	default:
+		return fmt.Errorf("tuic socks: unsupported auth method %d", resp[1])
+	}
+}
+
+func readSocks5Reply(r io.Reader) (netip.AddrPort, error) {
+	var hdr [4]byte
+	if _, err := io.ReadFull(r, hdr[:]); err != nil {
+		return netip.AddrPort{}, fmt.Errorf("tuic socks: read reply header: %w", err)
+	}
+	if hdr[0] != 0x05 {
+		return netip.AddrPort{}, fmt.Errorf("tuic socks: unexpected SOCKS version %d", hdr[0])
+	}
+	if hdr[1] != 0x00 {
+		return netip.AddrPort{}, fmt.Errorf("tuic socks: request rejected (code %d)", hdr[1])
+	}
+	addr, err := readSocks5Addr(r, hdr[3])
+	if err != nil {
+		return netip.AddrPort{}, err
+	}
+	var portBytes [2]byte
+	if _, err := io.ReadFull(r, portBytes[:]); err != nil {
+		return netip.AddrPort{}, fmt.Errorf("tuic socks: read reply port: %w", err)
+	}
+	return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil
+}
+
+func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) {
+	switch atyp {
+	case 0x01:
+		var b [4]byte
+		if _, err := io.ReadFull(r, b[:]); err != nil {
+			return netip.Addr{}, err
+		}
+		return netip.AddrFrom4(b), nil
+	case 0x04:
+		var b [16]byte
+		if _, err := io.ReadFull(r, b[:]); err != nil {
+			return netip.Addr{}, err
+		}
+		return netip.AddrFrom16(b), nil
+	case 0x03:
+		var l [1]byte
+		if _, err := io.ReadFull(r, l[:]); err != nil {
+			return netip.Addr{}, err
+		}
+		name := make([]byte, l[0])
+		if _, err := io.ReadFull(r, name); err != nil {
+			return netip.Addr{}, err
+		}
+		resolved, err := net.ResolveIPAddr("ip", string(name))
+		if err != nil {
+			return netip.Addr{}, fmt.Errorf("tuic socks: resolve domain reply %q: %w", name, err)
+		}
+		addr, ok := netip.AddrFromSlice(resolved.IP)
+		if !ok {
+			return netip.Addr{}, fmt.Errorf("tuic socks: unparseable domain reply address")
+		}
+		return addr, nil
+	default:
+		return netip.Addr{}, fmt.Errorf("tuic socks: unsupported SOCKS5 address type %d", atyp)
+	}
+}
+
+// halfCloseIdle bounds how long the surviving direction of a half-closed pair
+// may sit idle, so a peer that vanished mid-transfer cannot pin it forever.
+const halfCloseIdle = 2 * time.Minute
+
+type closeWriter interface {
+	CloseWrite() error
+}
+
+type readDeadliner interface {
+	SetReadDeadline(t time.Time) error
+}
+
+type guardedReader struct {
+	r     io.Reader
+	dl    readDeadliner
+	armed atomic.Bool
+}
+
+func newGuardedReader(r io.Reader) *guardedReader {
+	gr := &guardedReader{r: r}
+	if dl, ok := r.(readDeadliner); ok {
+		gr.dl = dl
+	}
+	return gr
+}
+
+func (g *guardedReader) Read(p []byte) (int, error) {
+	if g.armed.Load() && g.dl != nil {
+		_ = g.dl.SetReadDeadline(time.Now().Add(halfCloseIdle))
+	}
+	return g.r.Read(p)
+}
+
+func (g *guardedReader) arm() {
+	g.armed.Store(true)
+	if g.dl != nil {
+		_ = g.dl.SetReadDeadline(time.Now().Add(halfCloseIdle))
+	}
+}
+
+// PipeBiDirectional pipes data between two connections and tracks byte counts in each direction.
+func PipeBiDirectional(a, b io.ReadWriteCloser, upCounter, downCounter *atomic.Int64) {
+	PipeBiDirectionalContext(context.Background(), a, b, upCounter, downCounter)
+}
+
+func PipeBiDirectionalContext(ctx context.Context, a, b io.ReadWriteCloser, upCounter, downCounter *atomic.Int64) {
+	closeBoth := func() { _ = a.Close(); _ = b.Close() }
+	stop := context.AfterFunc(ctx, closeBoth)
+	defer stop()
+	ga := newGuardedReader(a)
+	gb := newGuardedReader(b)
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	pipe := func(dst io.Writer, dstGuard *guardedReader, src *guardedReader, counter *atomic.Int64) {
+		defer wg.Done()
+		buf := make([]byte, 32*1024)
+		for {
+			n, err := src.Read(buf)
+			if n > 0 {
+				if counter != nil {
+					counter.Add(int64(n))
+				}
+				if _, werr := dst.Write(buf[:n]); werr != nil {
+					closeBoth()
+					break
+				}
+			}
+			if err != nil {
+				if !errors.Is(err, io.EOF) {
+					closeBoth()
+				}
+				break
+			}
+		}
+		if cw, ok := dst.(closeWriter); ok {
+			_ = cw.CloseWrite()
+		} else if closer, ok := dst.(io.Closer); ok {
+			_ = closer.Close()
+		}
+		dstGuard.arm()
+	}
+
+	// a -> b (upload: client to upstream)
+	go pipe(b, gb, ga, upCounter)
+	// b -> a (download: upstream to client)
+	go pipe(a, ga, gb, downCounter)
+
+	wg.Wait()
+	_ = a.Close()
+	_ = b.Close()
+}
+
+// SOCKSBasePort is the first loopback port used for a TUIC inbound's
+// internal Xray SOCKS5 relay inbound.
+const SOCKSBasePort = 64000
+
+// relayPortSlots is how many ids fit in the TUIC relay port window (64001..65000).
+const relayPortSlots = 1000
+
+// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
+// its id, bounded within the dedicated range 64001..65000 so it never collides
+// with AmneziaWG (65101..65535), API (62789), or node egress (62800..63800).
+func SOCKSPortForInbound(inboundID int) int {
+	if inboundID <= 0 {
+		return SOCKSBasePort + 1
+	}
+	return SOCKSBasePort + 1 + (inboundID-1)%relayPortSlots
+}
+
+var (
+	socksPasswordOnce sync.Once
+	socksPassword     string
+)
+
+// SocksPassword returns the process-wide password used to authenticate into
+// every TUIC SOCKS5 relay inbound.
+func SocksPassword() string {
+	socksPasswordOnce.Do(func() {
+		var b [24]byte
+		if _, err := rand.Read(b[:]); err != nil {
+			socksPassword = fmt.Sprintf("tuic-fallback-%x", b)
+			return
+		}
+		socksPassword = base64.RawURLEncoding.EncodeToString(b[:])
+	})
+	return socksPassword
+}
+
+// SocksInboundSettings builds the JSON `settings` block for a stock Xray
+// SOCKS5 inbound with one username/password account per email, all sharing
+// password. UDP is enabled for UDP ASSOCIATE proxying.
+func SocksInboundSettings(emails []string, password string) ([]byte, error) {
+	type account struct {
+		User string `json:"user"`
+		Pass string `json:"pass"`
+	}
+	settings := struct {
+		Auth     string    `json:"auth"`
+		UDP      bool      `json:"udp"`
+		Accounts []account `json:"accounts"`
+	}{Auth: "password", UDP: true}
+	for _, email := range emails {
+		settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
+	}
+	return json.Marshal(settings)
+}

+ 416 - 0
internal/tuic/socks_bridge_test.go

@@ -0,0 +1,416 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"errors"
+	"io"
+	"net"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestBuildSocks5ConnectRequest(t *testing.T) {
+	// IPv4
+	ip4 := net.ParseIP("1.2.3.4")
+	req4 := buildSocks5ConnectRequest(&Address{Type: AddrTypeIPv4, IP: ip4, Port: 8080})
+	if len(req4) != 10 || req4[0] != 0x05 || req4[1] != 0x01 || req4[3] != 0x01 {
+		t.Fatalf("unexpected IPv4 CONNECT request: %x", req4)
+	}
+	if binary.BigEndian.Uint16(req4[8:10]) != 8080 {
+		t.Fatalf("expected port 8080, got %d", binary.BigEndian.Uint16(req4[8:10]))
+	}
+
+	// IPv6
+	ip6 := net.ParseIP("2001:db8::1")
+	req6 := buildSocks5ConnectRequest(&Address{Type: AddrTypeIPv6, IP: ip6, Port: 443})
+	if len(req6) != 22 || req6[3] != 0x04 {
+		t.Fatalf("unexpected IPv6 CONNECT request: %x", req6)
+	}
+	if binary.BigEndian.Uint16(req6[20:22]) != 443 {
+		t.Fatalf("expected port 443, got %d", binary.BigEndian.Uint16(req6[20:22]))
+	}
+
+	// Domain
+	reqD := buildSocks5ConnectRequest(&Address{Type: AddrTypeDomain, Host: "example.com", Port: 80})
+	if reqD == nil || reqD[3] != 0x03 || reqD[4] != byte(len("example.com")) {
+		t.Fatalf("unexpected Domain CONNECT request: %x", reqD)
+	}
+	if binary.BigEndian.Uint16(reqD[len(reqD)-2:]) != 80 {
+		t.Fatalf("expected port 80, got %d", binary.BigEndian.Uint16(reqD[len(reqD)-2:]))
+	}
+
+	// Nil target
+	if buildSocks5ConnectRequest(nil) != nil {
+		t.Fatalf("expected nil for nil target")
+	}
+}
+
+func TestBuildSocks5UDPHeader(t *testing.T) {
+	// IPv4
+	ip4 := net.ParseIP("192.168.1.1")
+	hdr4 := buildSocks5UDPHeader(&Address{Type: AddrTypeIPv4, IP: ip4, Port: 53})
+	if len(hdr4) != 10 || hdr4[3] != 0x01 || binary.BigEndian.Uint16(hdr4[8:10]) != 53 {
+		t.Fatalf("unexpected IPv4 UDP header: %x", hdr4)
+	}
+
+	// IPv6
+	ip6 := net.ParseIP("::1")
+	hdr6 := buildSocks5UDPHeader(&Address{Type: AddrTypeIPv6, IP: ip6, Port: 5353})
+	if len(hdr6) != 22 || hdr6[3] != 0x04 || binary.BigEndian.Uint16(hdr6[20:22]) != 5353 {
+		t.Fatalf("unexpected IPv6 UDP header: %x", hdr6)
+	}
+
+	// Domain
+	hdrD := buildSocks5UDPHeader(&Address{Type: AddrTypeDomain, Host: "dns.google", Port: 53})
+	if hdrD == nil || hdrD[3] != 0x03 || hdrD[4] != byte(len("dns.google")) {
+		t.Fatalf("unexpected Domain UDP header: %x", hdrD)
+	}
+
+	// Nil target
+	if buildSocks5UDPHeader(nil) != nil {
+		t.Fatalf("expected nil for nil target")
+	}
+}
+
+func TestBuildSocks5UDPRequestHonorsMaximumForAddressOverhead(t *testing.T) {
+	domain := &Address{Type: AddrTypeDomain, Host: strings.Repeat("a", 255), Port: 53}
+	packet, err := buildSocks5UDPRequest(domain, make([]byte, maxSafeUdpRelayPacketSize))
+	if err != nil {
+		t.Fatalf("maximum safe payload was rejected: %v", err)
+	}
+	if len(packet) != maxSocksUdpDatagramSize {
+		t.Fatalf("encoded SOCKS datagram = %d bytes, want %d", len(packet), maxSocksUdpDatagramSize)
+	}
+	if _, err := buildSocks5UDPRequest(domain, make([]byte, maxSafeUdpRelayPacketSize+1)); !errors.Is(err, ErrUdpPayloadTooLarge) {
+		t.Fatalf("oversized SOCKS datagram error = %v, want %v", err, ErrUdpPayloadTooLarge)
+	}
+}
+
+func TestCountingConn(t *testing.T) {
+	serverConn, clientConn := net.Pipe()
+	defer serverConn.Close()
+	defer clientConn.Close()
+
+	var bytesRead atomic.Int64
+	var bytesWritten atomic.Int64
+	c := &CountingConn{
+		Conn:         clientConn,
+		bytesRead:    &bytesRead,
+		bytesWritten: &bytesWritten,
+	}
+
+	go func() {
+		buf := make([]byte, 100)
+		n, _ := serverConn.Read(buf)
+		_, _ = serverConn.Write(buf[:n])
+	}()
+
+	msg := []byte("hello counting conn")
+	n, err := c.Write(msg)
+	if err != nil || n != len(msg) {
+		t.Fatalf("write failed: %v", err)
+	}
+	if bytesWritten.Load() != int64(len(msg)) {
+		t.Fatalf("expected %d written, got %d", len(msg), bytesWritten.Load())
+	}
+
+	resp := make([]byte, 100)
+	rn, err := c.Read(resp)
+	if err != nil || rn != len(msg) {
+		t.Fatalf("read failed: %v", err)
+	}
+	if bytesRead.Load() != int64(len(msg)) {
+		t.Fatalf("expected %d read, got %d", len(msg), bytesRead.Load())
+	}
+}
+
+func TestPipeBiDirectional(t *testing.T) {
+	a1, a2 := net.Pipe()
+	b1, b2 := net.Pipe()
+
+	var up, down atomic.Int64
+
+	done := make(chan struct{})
+	go func() {
+		PipeBiDirectional(a1, b1, &up, &down)
+		close(done)
+	}()
+
+	// Send from a2 -> a1 -> b1 -> b2 (upload)
+	testDataUp := []byte("upload stream test")
+	go func() {
+		_, _ = a2.Write(testDataUp)
+	}()
+	bufUp := make([]byte, len(testDataUp))
+	_, err := io.ReadFull(b2, bufUp)
+	if err != nil || !bytes.Equal(bufUp, testDataUp) {
+		t.Fatalf("upload read failed: %v", err)
+	}
+
+	// Send from b2 -> b1 -> a1 -> a2 (download)
+	testDataDown := []byte("download stream test")
+	go func() {
+		_, _ = b2.Write(testDataDown)
+	}()
+	bufDown := make([]byte, len(testDataDown))
+	_, err = io.ReadFull(a2, bufDown)
+	if err != nil || !bytes.Equal(bufDown, testDataDown) {
+		t.Fatalf("download read failed: %v", err)
+	}
+
+	_ = a2.Close()
+	_ = b2.Close()
+
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("PipeBiDirectional timed out waiting to finish")
+	}
+
+	if up.Load() < int64(len(testDataUp)) {
+		t.Fatalf("expected at least %d up, got %d", len(testDataUp), up.Load())
+	}
+	if down.Load() < int64(len(testDataDown)) {
+		t.Fatalf("expected at least %d down, got %d", len(testDataDown), down.Load())
+	}
+}
+
+func startMockSocks5Server(t *testing.T, expectedUser, expectedPass string) (string, func()) {
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("failed to listen: %v", err)
+	}
+
+	stop := make(chan struct{})
+
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				select {
+				case <-stop:
+					return
+				default:
+					return
+				}
+			}
+			go handleMockSocksConn(conn, expectedUser, expectedPass)
+		}
+	}()
+
+	return ln.Addr().String(), func() {
+		close(stop)
+		_ = ln.Close()
+	}
+}
+
+func handleMockSocksConn(conn net.Conn, expectedUser, expectedPass string) {
+	defer conn.Close()
+	// Read greeting
+	var greeting [4]byte
+	if _, err := io.ReadFull(conn, greeting[:]); err != nil {
+		return
+	}
+	// Select user/password auth (0x02)
+	if _, err := conn.Write([]byte{0x05, 0x02}); err != nil {
+		return
+	}
+	// Auth negotiation
+	var authVer [2]byte
+	if _, err := io.ReadFull(conn, authVer[:]); err != nil {
+		return
+	}
+	uLen := int(authVer[1])
+	user := make([]byte, uLen)
+	if _, err := io.ReadFull(conn, user); err != nil {
+		return
+	}
+	var pLen [1]byte
+	if _, err := io.ReadFull(conn, pLen[:]); err != nil {
+		return
+	}
+	pass := make([]byte, int(pLen[0]))
+	if _, err := io.ReadFull(conn, pass); err != nil {
+		return
+	}
+
+	if string(user) != expectedUser || string(pass) != expectedPass {
+		_, _ = conn.Write([]byte{0x01, 0x01}) // auth failure
+		return
+	}
+	_, _ = conn.Write([]byte{0x01, 0x00}) // auth success
+
+	// Read command
+	var cmdHdr [4]byte
+	if _, err := io.ReadFull(conn, cmdHdr[:]); err != nil {
+		return
+	}
+	cmd := cmdHdr[1]
+	atyp := cmdHdr[3]
+
+	// Read dest address
+	switch atyp {
+	case 0x01:
+		var ip [4]byte
+		_, _ = io.ReadFull(conn, ip[:])
+	case 0x04:
+		var ip [16]byte
+		_, _ = io.ReadFull(conn, ip[:])
+	case 0x03:
+		var dLen [1]byte
+		_, _ = io.ReadFull(conn, dLen[:])
+		domain := make([]byte, dLen[0])
+		_, _ = io.ReadFull(conn, domain)
+	}
+	var port [2]byte
+	_, _ = io.ReadFull(conn, port[:])
+
+	switch cmd {
+	case 0x01: // CONNECT
+		// Send success reply: 0x05 0x00 0x00 0x01 (IPv4 127.0.0.1:0)
+		_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1f, 0x90})
+		// Echo server for testing
+		_, _ = io.Copy(conn, conn)
+	case 0x03: // UDP ASSOCIATE
+		// Bind a UDP listener for the mock
+		u, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
+		if err != nil {
+			return
+		}
+		defer u.Close()
+		bindAddr := u.LocalAddr().(*net.UDPAddr)
+		bindPort := uint16(bindAddr.Port)
+
+		resp := make([]byte, 10)
+		resp[0] = 0x05
+		resp[1] = 0x00
+		resp[2] = 0x00
+		resp[3] = 0x01
+		copy(resp[4:8], bindAddr.IP.To4())
+		binary.BigEndian.PutUint16(resp[8:10], bindPort)
+		if _, err := conn.Write(resp); err != nil {
+			return
+		}
+
+		go func() {
+			buf := make([]byte, maxUdpRelayPacketSize)
+			for {
+				n, remoteAddr, err := u.ReadFrom(buf)
+				if err != nil {
+					return
+				}
+				_, _ = u.WriteTo(buf[:n], remoteAddr)
+			}
+		}()
+
+		// Keep conn open until closed
+		buf := make([]byte, 1)
+		_, _ = conn.Read(buf)
+	}
+}
+
+func TestSocksRelayDialTCP(t *testing.T) {
+	addr, cleanup := startMockSocks5Server(t, "[email protected]", "secretpass")
+	defer cleanup()
+
+	relay := &SocksRelay{
+		Addr:     addr,
+		Password: "secretpass",
+	}
+
+	target := &Address{
+		Type: AddrTypeIPv4,
+		IP:   net.ParseIP("93.184.216.34"),
+		Port: 80,
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+	defer cancel()
+
+	conn, err := relay.DialTCP(ctx, "[email protected]", target)
+	if err != nil {
+		t.Fatalf("DialTCP failed: %v", err)
+	}
+	defer conn.Close()
+
+	// Send echo payload
+	msg := []byte("ping through socks")
+	if _, err := conn.Write(msg); err != nil {
+		t.Fatalf("write failed: %v", err)
+	}
+	reply := make([]byte, len(msg))
+	if _, err := io.ReadFull(conn, reply); err != nil {
+		t.Fatalf("read failed: %v", err)
+	}
+	if !bytes.Equal(reply, msg) {
+		t.Fatalf("expected %q, got %q", msg, reply)
+	}
+}
+
+func TestSocksRelayDialUDP(t *testing.T) {
+	addr, cleanup := startMockSocks5Server(t, "[email protected]", "secretpass")
+	defer cleanup()
+
+	relay := &SocksRelay{
+		Addr:     addr,
+		Password: "secretpass",
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+	defer cancel()
+
+	session, err := relay.DialUDP(ctx, "[email protected]")
+	if err != nil {
+		t.Fatalf("DialUDP failed: %v", err)
+	}
+	defer session.Close()
+
+	target := &Address{
+		Type: AddrTypeIPv4,
+		IP:   net.ParseIP("8.8.8.8"),
+		Port: 53,
+	}
+	payload := []byte("dns packet payload")
+
+	n, err := session.Send(target, payload)
+	if err != nil || n == 0 {
+		t.Fatalf("Send failed: %v", err)
+	}
+
+	buf := make([]byte, 2048)
+	recvAddr, recvPayload, err := session.Receive(buf)
+	if err != nil {
+		t.Fatalf("Receive failed: %v", err)
+	}
+	if !bytes.Equal(recvPayload, payload) {
+		t.Fatalf("expected payload %q, got %q", payload, recvPayload)
+	}
+	if recvAddr.IP.String() != "8.8.8.8" || recvAddr.Port != 53 {
+		t.Fatalf("unexpected addr: %v", recvAddr)
+	}
+}
+
+func TestSOCKSPortForInboundKeepsEverySlotInsideTheWindow(t *testing.T) {
+	for id := 1; id <= 3000; id++ {
+		port := SOCKSPortForInbound(id)
+		if port < 64001 || port > 65000 {
+			t.Fatalf("id %d derived port %d outside window [64001, 65000]", id, port)
+		}
+	}
+	if got := SOCKSPortForInbound(1); got != 64001 {
+		t.Fatalf("expected 64001 for id 1, got %d", got)
+	}
+	if got := SOCKSPortForInbound(1000); got != 65000 {
+		t.Fatalf("expected 65000 for id 1000, got %d", got)
+	}
+	if got := SOCKSPortForInbound(1001); got != 64001 {
+		t.Fatalf("expected 64001 for id 1001, got %d", got)
+	}
+	if got := SOCKSPortForInbound(0); got != 64001 {
+		t.Fatalf("expected 64001 for id 0, got %d", got)
+	}
+}

+ 200 - 0
internal/tuic/stream_udp_lifecycle_test.go

@@ -0,0 +1,200 @@
+package tuic
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"io"
+	"net"
+	"testing"
+	"time"
+
+	serverquic "github.com/apernet/quic-go"
+	"github.com/google/uuid"
+	clientquic "github.com/quic-go/quic-go"
+)
+
+func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *clientquic.Conn, uuid.UUID, string) {
+	t.Helper()
+	cert, key := generateTestCert(t)
+	clientID := uuid.New()
+	password := "lifecycle-test-password"
+	server, err := NewServer(Instance{
+		Id: 99101, Tag: "lifecycle-test", Listen: "127.0.0.1", Port: 0,
+		Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
+		AuthenticationTimeout: 2, MaxIdleTime: 30,
+		Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: email}},
+	}, &SocksRelay{Addr: relayAddr, Password: "lifecycle-socks-password"})
+	if err != nil {
+		t.Fatalf("create TUIC server: %v", err)
+	}
+	if err := server.Start(); err != nil {
+		t.Fatalf("start TUIC server: %v", err)
+	}
+	t.Cleanup(func() { _ = server.Close() })
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	client, err := clientquic.DialAddr(ctx, server.packetConn.LocalAddr().String(),
+		&tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}},
+		&clientquic.Config{EnableDatagrams: true})
+	if err != nil {
+		t.Fatalf("dial TUIC server: %v", err)
+	}
+	t.Cleanup(func() { _ = client.CloseWithError(0, "test complete") })
+
+	tlsState := client.ConnectionState().TLS
+	token, err := tlsState.ExportKeyingMaterial(string(clientID[:]), []byte(password), 32)
+	if err != nil {
+		t.Fatalf("derive authentication token: %v", err)
+	}
+	stream, err := client.OpenUniStreamSync(ctx)
+	if err != nil {
+		t.Fatalf("open authentication stream: %v", err)
+	}
+	auth := append([]byte{ProtocolVersion, CmdAuthenticate}, clientID[:]...)
+	auth = append(auth, token...)
+	if _, err := stream.Write(auth); err != nil {
+		t.Fatalf("write authentication: %v", err)
+	}
+	if err := stream.Close(); err != nil {
+		t.Fatalf("close authentication stream: %v", err)
+	}
+	return server, client, clientID, password
+}
+
+func authenticatedServerConnection(t *testing.T, server *Server, clientID uuid.UUID) (*serverquic.Conn, *User) {
+	t.Helper()
+	id := [16]byte(clientID)
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		server.activeConnsMu.Lock()
+		for conn, user := range server.activeConns[id] {
+			server.activeConnsMu.Unlock()
+			return conn, user
+		}
+		server.activeConnsMu.Unlock()
+		time.Sleep(time.Millisecond)
+	}
+	t.Fatal("server did not register authenticated QUIC connection")
+	return nil, nil
+}
+
+func TestMalformedBiStreamDelayedFINReleasesReceiveCredit(t *testing.T) {
+	_, client, _, _ := startLifecycleTestServer(t, "127.0.0.1:1", "bidi-lifecycle@x")
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	for i := 0; i < 110; i++ {
+		openCtx, openCancel := context.WithTimeout(ctx, 700*time.Millisecond)
+		stream, err := client.OpenStreamSync(openCtx)
+		openCancel()
+		if err != nil {
+			t.Fatalf("bidirectional stream %d blocked after unsupported commands: %v", i+1, err)
+		}
+		if _, err := stream.Write([]byte{ProtocolVersion, 0xff}); err != nil {
+			t.Fatalf("write unsupported command %d: %v", i+1, err)
+		}
+		stream.SetReadDeadline(time.Now().Add(time.Second))
+		if _, err := io.Copy(io.Discard, stream); err != nil {
+			t.Fatalf("wait for unsupported stream %d to close: %v", i+1, err)
+		}
+		_ = stream.Close()
+	}
+}
+
+func TestDownstreamUDPResponseRefreshesAssociationIdleTime(t *testing.T) {
+	socksAddr, cleanup := startMockSocks5Server(t, "udp-lifecycle@x", "lifecycle-socks-password")
+	defer cleanup()
+	server, client, clientID, _ := startLifecycleTestServer(t, socksAddr, "udp-lifecycle@x")
+	serverConn, user := authenticatedServerConnection(t, server, clientID)
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	session, err := server.relay.DialUDP(ctx, user.Email)
+	if err != nil {
+		t.Fatalf("open SOCKS UDP session: %v", err)
+	}
+	t.Cleanup(func() { _ = session.Close() })
+	const associationID uint16 = 61244
+	oldActive := time.Now().Add(-udpAssociationIdleTimeout - time.Second)
+	association := &udpAssociation{
+		responseTransport: packetTransportDatagram,
+		relay:             &udpRelaySession{relay: session, responseTransport: packetTransportDatagram},
+		lastActive:        oldActive,
+	}
+	registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
+	registry.associations[associationID] = association
+	responseDone := make(chan struct{})
+	go func() {
+		defer close(responseDone)
+		server.relayUDPResponses(ctx, serverConn, user, associationID, association, registry, association.relay)
+	}()
+
+	target := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}
+	if _, err := session.Send(target, []byte("seed")); err != nil {
+		t.Fatalf("send SOCKS seed datagram: %v", err)
+	}
+	response, err := client.ReceiveDatagram(ctx)
+	if err != nil {
+		t.Fatalf("receive echoed UDP response: %v", err)
+	}
+	if len(response) < 2 || response[0] != ProtocolVersion || response[1] != CmdPacket {
+		t.Fatalf("unexpected TUIC UDP response: %x", response)
+	}
+	reader := bytes.NewReader(response[2:])
+	header, err := ReadPacketHeader(reader)
+	if err != nil {
+		t.Fatalf("read response header: %v", err)
+	}
+	got, err := readPacketPayload(reader, header)
+	if err != nil || !bytes.Equal(got, []byte("seed")) {
+		t.Fatalf("echo response payload = %q, error=%v", got, err)
+	}
+	if header.AssocID != associationID {
+		t.Fatalf("response association id = %d, want %d", header.AssocID, associationID)
+	}
+
+	registry.mu.Lock()
+	refreshedAt := association.lastActive
+	registry.mu.Unlock()
+	if !refreshedAt.After(oldActive) {
+		t.Fatal("successful downstream response did not refresh association activity")
+	}
+	registry.reapIdle(oldActive.Add(udpAssociationIdleTimeout + time.Second))
+	registry.mu.Lock()
+	remaining := registry.associations[associationID]
+	registry.mu.Unlock()
+	if remaining != association {
+		t.Fatal("association was reaped despite a recently delivered downstream response")
+	}
+	_ = session.Close()
+	select {
+	case <-responseDone:
+	case <-time.After(time.Second):
+		t.Fatal("UDP response relay did not stop after SOCKS session closed")
+	}
+}
+
+func TestUdpAssociationTouchDoesNotRefreshReusedID(t *testing.T) {
+	registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
+	addr := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}
+	header := &PacketHeader{AssocID: 17, PktID: 1, FragTotal: 1, FragID: 0, Size: 1, Addr: addr}
+	old, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("x"))
+	if !complete {
+		t.Fatal("failed to create first association generation")
+	}
+	oldTime := old.lastActive
+	if !registry.dissociate(header.AssocID) {
+		t.Fatal("failed to dissociate first association generation")
+	}
+	newGeneration, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("x"))
+	if !complete || newGeneration == old {
+		t.Fatal("failed to create replacement association generation")
+	}
+	if registry.touch(header.AssocID, old, oldTime.Add(time.Hour)) {
+		t.Fatal("late response refreshed a replacement association generation")
+	}
+	if !newGeneration.lastActive.Before(oldTime.Add(time.Hour)) {
+		t.Fatal("replacement association timestamp changed after stale touch")
+	}
+}

+ 47 - 23
internal/tuic/types.go

@@ -1,9 +1,11 @@
 package tuic
 
 import (
+	"crypto/sha256"
 	"encoding/json"
 	"fmt"
 	"net"
+	"os"
 	"slices"
 	"strconv"
 	"strings"
@@ -26,9 +28,10 @@ type TuicServerSettings struct {
 }
 
 type TuicClientSettings struct {
-	UUID     string `json:"uuid"`
-	Password string `json:"password"`
-	Email    string `json:"email"`
+	TrafficID int    `json:"-"`
+	UUID      string `json:"uuid"`
+	Password  string `json:"password"`
+	Email     string `json:"email"`
 }
 
 type Instance struct {
@@ -38,48 +41,50 @@ type Instance struct {
 	Port                  int
 	Certificate           string
 	PrivateKey            string
-	CongestionControl     string
+	CongestionControl     string // Applied to new native QUIC connections and exported to client profiles.
 	ALPN                  []string
-	UDPRelayMode          string
+	UDPRelayMode          string // Default mode exported to client links; the listener accepts both modes.
 	ZeroRTTHandshake      bool
 	LogLevel              string
 	MaxIdleTime           int
 	AuthenticationTimeout int
 	MaxUdpRelayPacketSize int
-	SNI                   string
+	SNI                   string // Exported to client links; it doesn't change the native listener certificate.
 	Clients               []TuicClientSettings
 }
 
 func (inst Instance) BindTo() string {
-	listen := inst.Listen
-	if listen == "" {
-		listen = "0.0.0.0"
-	}
-	return net.JoinHostPort(listen, strconv.Itoa(inst.Port))
+	return net.JoinHostPort(inst.Listen, strconv.Itoa(inst.Port))
 }
 
 func (inst Instance) StructuralFingerprint() string {
 	parts := []string{
 		inst.BindTo(),
-		inst.Certificate,
-		inst.PrivateKey,
-		inst.CongestionControl,
+		fingerprintMaterial(inst.Certificate),
+		fingerprintMaterial(inst.PrivateKey),
 		strings.Join(inst.ALPN, ","),
-		inst.UDPRelayMode,
 		strconv.FormatBool(inst.ZeroRTTHandshake),
-		inst.LogLevel,
 		strconv.Itoa(inst.MaxIdleTime),
 		strconv.Itoa(inst.AuthenticationTimeout),
 		strconv.Itoa(inst.MaxUdpRelayPacketSize),
-		inst.SNI,
 	}
 	return strings.Join(parts, "|")
 }
 
+func fingerprintMaterial(value string) string {
+	material := []byte(value)
+	if value != "" && !strings.Contains(value, "-----BEGIN ") {
+		if file, err := os.ReadFile(value); err == nil {
+			material = file
+		}
+	}
+	return fmt.Sprintf("%x", sha256.Sum256(material))
+}
+
 func (inst Instance) UsersFingerprint() string {
 	pairs := make([]string, 0, len(inst.Clients))
 	for _, c := range inst.Clients {
-		pairs = append(pairs, fmt.Sprintf("%s=%s:%s", c.Email, c.UUID, c.Password))
+		pairs = append(pairs, fmt.Sprintf("%d:%s=%s:%s", c.TrafficID, c.Email, c.UUID, c.Password))
 	}
 	slices.Sort(pairs)
 	return strings.Join(pairs, "|")
@@ -120,6 +125,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 			SNI                   string   `json:"sni"`
 		} `json:"server"`
 		Clients []struct {
+			TrafficID  int    `json:"traffic_id"`
 			UUID       string `json:"uuid"`
 			ID         string `json:"id"`
 			Password   string `json:"password"`
@@ -187,8 +193,10 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		}
 	}
 
-	if cc == "" {
-		cc = "bbr"
+	if normalized, err := NormalizeCongestionControl(cc); err == nil {
+		cc = normalized
+	} else {
+		cc = "new_reno"
 	}
 	if len(alpn) == 0 {
 		alpn = []string{"h3", "spdy/3.1"}
@@ -207,6 +215,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 	}
 	if maxPacketSize <= 0 {
 		maxPacketSize = 1500
+	} else if maxPacketSize > maxSafeUdpRelayPacketSize && maxPacketSize <= maxLegacyUdpRelayPacketSize {
+		maxPacketSize = maxSafeUdpRelayPacketSize
 	}
 
 	clients := make([]TuicClientSettings, 0, len(parsed.Clients))
@@ -222,9 +232,10 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 			continue
 		}
 		clients = append(clients, TuicClientSettings{
-			UUID:     uuidVal,
-			Password: c.Password,
-			Email:    c.Email,
+			TrafficID: c.TrafficID,
+			UUID:      uuidVal,
+			Password:  c.Password,
+			Email:     c.Email,
 		})
 	}
 
@@ -247,3 +258,16 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		Clients:               clients,
 	}, true
 }
+
+func NormalizeCongestionControl(value string) (string, error) {
+	switch strings.ToLower(strings.TrimSpace(value)) {
+	case "", "bbr":
+		return "bbr", nil
+	case "cubic":
+		return "cubic", nil
+	case "new_reno", "reno":
+		return "new_reno", nil
+	default:
+		return "", fmt.Errorf("tuic: unsupported congestion controller %q", value)
+	}
+}

+ 37 - 1
internal/tuic/types_test.go

@@ -1,6 +1,8 @@
 package tuic
 
 import (
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -74,6 +76,26 @@ func TestInstanceFromInbound(t *testing.T) {
 	})
 }
 
+func TestStructuralFingerprintDetectsCertificateRenewalAtSamePath(t *testing.T) {
+	dir := t.TempDir()
+	certPath := filepath.Join(dir, "fullchain.pem")
+	keyPath := filepath.Join(dir, "privkey.pem")
+	if err := os.WriteFile(certPath, []byte("certificate-v1"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(keyPath, []byte("key-v1"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	inst := Instance{Listen: "0.0.0.0", Port: 443, Certificate: certPath, PrivateKey: keyPath}
+	before := inst.StructuralFingerprint()
+	if err := os.WriteFile(certPath, []byte("certificate-v2"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if before == inst.StructuralFingerprint() {
+		t.Fatal("certificate renewal at the same path did not change the structural fingerprint")
+	}
+}
+
 func TestFingerprints(t *testing.T) {
 	inst1 := Instance{
 		Id:                1,
@@ -101,6 +123,20 @@ func TestFingerprints(t *testing.T) {
 	if inst1.UsersFingerprint() != inst2.UsersFingerprint() {
 		t.Fatalf("users fingerprint must be stable under reordering: %s vs %s", inst1.UsersFingerprint(), inst2.UsersFingerprint())
 	}
+
+	profileOnlyChange := inst1
+	profileOnlyChange.CongestionControl = "new_reno"
+	profileOnlyChange.UDPRelayMode = "quic"
+	profileOnlyChange.SNI = "client-profile.example"
+	if inst1.StructuralFingerprint() != profileOnlyChange.StructuralFingerprint() {
+		t.Fatal("client-profile defaults must not restart the native TUIC listener")
+	}
+
+	packetLimitChange := inst1
+	packetLimitChange.MaxUdpRelayPacketSize = 4096
+	if inst1.StructuralFingerprint() == packetLimitChange.StructuralFingerprint() {
+		t.Fatal("changing the UDP packet limit must update the native TUIC listener")
+	}
 }
 
 func TestBindTo(t *testing.T) {
@@ -108,7 +144,7 @@ func TestBindTo(t *testing.T) {
 		listen string
 		want   string
 	}{
-		{"", "0.0.0.0:8443"},
+		{"", ":8443"},
 		{"127.0.0.1", "127.0.0.1:8443"},
 		{"::", "[::]:8443"},
 		{"2001:db8::1", "[2001:db8::1]:8443"},

+ 186 - 0
internal/tuic/udp_associations.go

@@ -0,0 +1,186 @@
+package tuic
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"time"
+)
+
+const (
+	maxUdpAssociations        = 256
+	udpAssociationIdleTimeout = 5 * time.Minute
+)
+
+var errUdpAssociationClosed = errors.New("tuic: UDP association is closed")
+
+type udpAssociation struct {
+	responseTransport uint8
+	relay             *udpRelaySession
+	lastActive        time.Time
+}
+
+type udpAssociationRegistry struct {
+	mu                  sync.Mutex
+	associations        map[uint16]*udpAssociation
+	datagramReassembler *packetReassembler
+	streamReassembler   *packetReassembler
+	maxPacketSize       int
+}
+
+func newUdpAssociationRegistry(maxPacketSize int) *udpAssociationRegistry {
+	return &udpAssociationRegistry{
+		associations:        make(map[uint16]*udpAssociation),
+		datagramReassembler: newPacketReassembler(maxPacketSize),
+		streamReassembler:   newPacketReassembler(maxPacketSize),
+		maxPacketSize:       maxPacketSize,
+	}
+}
+
+func (r *udpAssociationRegistry) feed(transport uint8, hdr *PacketHeader, payload []byte) (*udpAssociation, *Address, []byte, bool) {
+	if !validPacketFragment(hdr, payload, r.maxPacketSize) {
+		return nil, nil, nil, false
+	}
+
+	now := time.Now()
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.expireLocked(now)
+
+	association := r.associations[hdr.AssocID]
+	if association == nil {
+		if len(r.associations) >= maxUdpAssociations {
+			return nil, nil, nil, false
+		}
+		association = &udpAssociation{responseTransport: transport, lastActive: now}
+		r.associations[hdr.AssocID] = association
+	}
+	association.lastActive = now
+
+	reassembler := r.datagramReassembler
+	if transport == packetTransportStream {
+		reassembler = r.streamReassembler
+	}
+	addr, completePayload, complete := reassembler.feed(transport, hdr, payload)
+	return association, addr, completePayload, complete
+}
+
+func validPacketFragment(hdr *PacketHeader, payload []byte, maxPacketSize int) bool {
+	if hdr == nil || hdr.FragTotal == 0 || hdr.FragID >= hdr.FragTotal || int(hdr.Size) != len(payload) || len(payload) > maxPacketSize {
+		return false
+	}
+	if hdr.FragTotal == 1 {
+		return hdr.FragID == 0 && isPacketTarget(hdr.Addr)
+	}
+	return (hdr.FragID != 0 || isPacketTarget(hdr.Addr)) &&
+		(hdr.FragID == 0 || hdr.Addr == nil || hdr.Addr.Type == AddrTypeNone)
+}
+
+func (r *udpAssociationRegistry) ensureRelay(ctx context.Context, assocID uint16, expected *udpAssociation, user *User, relay *SocksRelay) (*udpAssociation, bool, error) {
+	r.mu.Lock()
+	association := r.associations[assocID]
+	if association == nil || association != expected {
+		r.mu.Unlock()
+		return nil, false, errUdpAssociationClosed
+	}
+	if association.relay != nil {
+		association.lastActive = time.Now()
+		r.mu.Unlock()
+		return association, false, nil
+	}
+	r.mu.Unlock()
+
+	socksSession, err := relay.DialUDP(ctx, user.Email)
+	if err != nil {
+		return nil, false, err
+	}
+
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	current := r.associations[assocID]
+	if current != association {
+		_ = socksSession.Close()
+		return nil, false, errUdpAssociationClosed
+	}
+	if current.relay != nil {
+		_ = socksSession.Close()
+		current.lastActive = time.Now()
+		return current, false, nil
+	}
+	current.relay = &udpRelaySession{relay: socksSession, responseTransport: current.responseTransport}
+	current.lastActive = time.Now()
+	return current, true, nil
+}
+
+func (r *udpAssociationRegistry) dissociate(assocID uint16) bool {
+	r.mu.Lock()
+	association := r.associations[assocID]
+	delete(r.associations, assocID)
+	r.datagramReassembler.clearAssociation(assocID)
+	r.streamReassembler.clearAssociation(assocID)
+	r.mu.Unlock()
+	if association == nil {
+		return false
+	}
+	if association.relay != nil {
+		_ = association.relay.relay.Close()
+	}
+	return true
+}
+
+func (r *udpAssociationRegistry) touch(assocID uint16, expected *udpAssociation, now time.Time) bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	current := r.associations[assocID]
+	if current == nil || current != expected {
+		return false
+	}
+	current.lastActive = now
+	return true
+}
+
+func (r *udpAssociationRegistry) reapIdle(now time.Time) {
+	r.mu.Lock()
+	r.expireLocked(now)
+	r.mu.Unlock()
+}
+
+func (r *udpAssociationRegistry) expireLocked(now time.Time) {
+	for assocID, association := range r.associations {
+		if now.Sub(association.lastActive) <= udpAssociationIdleTimeout {
+			continue
+		}
+		delete(r.associations, assocID)
+		r.datagramReassembler.clearAssociation(assocID)
+		r.streamReassembler.clearAssociation(assocID)
+		if association.relay != nil {
+			_ = association.relay.relay.Close()
+		}
+	}
+}
+
+func (r *udpAssociationRegistry) closeAll() {
+	r.mu.Lock()
+	for _, association := range r.associations {
+		if association.relay != nil {
+			_ = association.relay.relay.Close()
+		}
+	}
+	r.associations = make(map[uint16]*udpAssociation)
+	r.datagramReassembler.clearAll()
+	r.streamReassembler.clearAll()
+	r.mu.Unlock()
+}
+
+func (r *udpAssociationRegistry) release(id uint16, expected *udpAssociation) {
+	r.mu.Lock()
+	if r.associations[id] == expected {
+		delete(r.associations, id)
+		r.datagramReassembler.clearAssociation(id)
+		r.streamReassembler.clearAssociation(id)
+	}
+	r.mu.Unlock()
+	if expected.relay != nil {
+		_ = expected.relay.relay.Close()
+	}
+}

+ 47 - 0
internal/util/version/version.go

@@ -0,0 +1,47 @@
+// Package version compares 3x-ui release versions ("v3.8.0" or "3.8.0").
+package version
+
+import (
+	"strconv"
+	"strings"
+)
+
+// Compare returns -1, 0 or 1 as a is older than, equal to or newer than b, and
+// false when either side is not a plain MAJOR.MINOR.PATCH version.
+func Compare(a, b string) (int, bool) {
+	aParts, okA := parse(a)
+	bParts, okB := parse(b)
+	if !okA || !okB {
+		return 0, false
+	}
+	for i := range len(aParts) {
+		if aParts[i] > bParts[i] {
+			return 1, true
+		}
+		if aParts[i] < bParts[i] {
+			return -1, true
+		}
+	}
+	return 0, true
+}
+
+// Normalize strips surrounding space and a leading "v" from a version tag.
+func Normalize(v string) string {
+	return strings.TrimPrefix(strings.TrimSpace(v), "v")
+}
+
+func parse(v string) ([3]int, bool) {
+	var result [3]int
+	parts := strings.Split(Normalize(v), ".")
+	if len(parts) != 3 {
+		return result, false
+	}
+	for i, part := range parts {
+		n, err := strconv.Atoi(part)
+		if err != nil {
+			return result, false
+		}
+		result[i] = n
+	}
+	return result, true
+}

+ 12 - 0
internal/util/version/version_test.go

@@ -0,0 +1,12 @@
+package version
+
+import "testing"
+
+func TestCompareRejectsUnexpectedFormats(t *testing.T) {
+	if _, ok := Compare("latest", "2.9.3"); ok {
+		t.Fatal("expected non-semver latest tag to be rejected")
+	}
+	if _, ok := Compare("v2.9", "2.9.3"); ok {
+		t.Fatal("expected short version to be rejected")
+	}
+}

+ 10 - 4
internal/web/job/periodic_traffic_reset_nodes_test.go

@@ -40,11 +40,17 @@ func (g *resetGate) waitAll(t *testing.T, want int32) {
 	}
 }
 
-// resetNode is a node whose every traffic reset hangs until the gate opens.
-func resetNode(t *testing.T, gate *resetGate, name string) int {
+// resetNode is a node hosting inboundTag whose every traffic reset hangs until the
+// gate opens; it lists the inbound so the master can resolve its node-side id.
+func resetNode(t *testing.T, gate *resetGate, name, inboundTag string) int {
 	t.Helper()
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		_, _ = io.Copy(io.Discard, r.Body)
+		if strings.HasSuffix(r.URL.Path, "/panel/api/inbounds/list") {
+			w.Header().Set("Content-Type", "application/json")
+			_, _ = fmt.Fprintf(w, `{"success":true,"obj":[{"id":1,"tag":%q}]}`, inboundTag)
+			return
+		}
 		if strings.Contains(r.URL.Path, "resetTraffic") {
 			gate.entered.Add(1)
 			select {
@@ -93,7 +99,7 @@ func TestPeriodicResetReachesClientNodesConcurrently(t *testing.T) {
 	gate := newResetFleet(t)
 	db := database.GetDB()
 	for i := range 3 {
-		nodeID := resetNode(t, gate, fmt.Sprintf("client-node-%d", i))
+		nodeID := resetNode(t, gate, fmt.Sprintf("client-node-%d", i), "reset-client-"+strconv.Itoa(i))
 		email := fmt.Sprintf("cycle-%d@node", i)
 		client := model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-00000000000%d", i), Enable: true, TrafficReset: "daily"}
 		settings, _ := json.Marshal(map[string]any{"clients": []model.Client{client}})
@@ -121,7 +127,7 @@ func TestPeriodicResetReachesClientNodesConcurrently(t *testing.T) {
 func TestPeriodicResetReachesInboundNodesConcurrently(t *testing.T) {
 	gate := newResetFleet(t)
 	for i := range 3 {
-		nodeID := resetNode(t, gate, fmt.Sprintf("inbound-node-%d", i))
+		nodeID := resetNode(t, gate, fmt.Sprintf("inbound-node-%d", i), "reset-inbound-"+strconv.Itoa(i))
 		ib := model.Inbound{
 			UserId: 1, Enable: true, Port: 47100 + i, Protocol: model.VLESS, NodeID: &nodeID,
 			Tag: "reset-inbound-" + strconv.Itoa(i), TrafficReset: "daily", Settings: `{"clients":[]}`,

+ 57 - 30
internal/web/job/tuic_job.go

@@ -1,6 +1,7 @@
 package job
 
 import (
+	"fmt"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -18,6 +19,13 @@ func NewTuicJob() *TuicJob {
 }
 
 func (j *TuicJob) Run() {
+	tuicJournalMu.Lock()
+	journalErr := j.replayTuicJournal()
+	tuicJournalMu.Unlock()
+	if journalErr != nil {
+		logger.Warning("tuic job: recover traffic journal failed:", journalErr)
+	}
+
 	desired, err := j.inboundService.DesiredTuicInstances()
 	if err != nil {
 		logger.Warning("tuic job: get desired instances failed:", err)
@@ -32,41 +40,18 @@ func (j *TuicJob) Run() {
 	mgr := tuic.GetManager()
 	mgr.Reconcile(desired)
 
-	deltas := mgr.CollectTraffic()
+	_, clientDeltas := mgr.CollectAllTraffic()
 	onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
 
-	inboundUp := make(map[string]int64)
-	inboundDown := make(map[string]int64)
-	for _, d := range deltas {
-		inboundUp[d.Tag] += d.Up
-		inboundDown[d.Tag] += d.Down
-	}
-
-	traffics := make([]*xray.Traffic, 0, len(inboundUp))
-	for tag, up := range inboundUp {
-		traffics = append(traffics, &xray.Traffic{
-			IsInbound: true,
-			Tag:       tag,
-			Up:        up,
-			Down:      inboundDown[tag],
-		})
-	}
+	clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
 
-	// Build zero-byte client traffic entries for active clients so adjustTraffics can
-	// activate delayed-start expiryTime for TUIC clients without inflating traffic.
-	clientTraffics := make([]*xray.ClientTraffic, 0, len(onlineEmails))
-	for _, email := range onlineEmails {
-		clientTraffics = append(clientTraffics, &xray.ClientTraffic{
-			Email: email,
-			Up:    0,
-			Down:  0,
-		})
-	}
-
-	if len(traffics) > 0 || len(clientTraffics) > 0 {
-		needRestart, _, err := j.inboundService.AddTraffic(traffics, clientTraffics)
+	// Inbound total traffic is already metered through the loopback SOCKS relay
+	// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
+	if len(clientTraffics) > 0 {
+		needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
 		if err != nil {
 			logger.Warning("tuic job: add traffic failed:", err)
+			mgr.RequeueClientTraffic(clientDeltas)
 		} else if needRestart {
 			if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
 				mgr.Reconcile(desired)
@@ -82,3 +67,45 @@ func (j *TuicJob) Run() {
 
 	j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
 }
+
+// FlushStoppedTraffic persists counters drained when the TUIC manager stops its
+// listeners. Call it after scheduled jobs have stopped and before the traffic
+// writer shuts down.
+func (j *TuicJob) FlushStoppedTraffic() error {
+	return j.flushTuicJournal()
+}
+
+func aggregateTuicClientTraffic(clientDeltas []tuic.ClientTrafficDelta, onlineEmails []string) []*xray.ClientTraffic {
+	clientTrafficMap := make(map[string]*xray.ClientTraffic, len(clientDeltas)+len(onlineEmails))
+	for _, cd := range clientDeltas {
+		key := cd.Email
+		if cd.TrafficID > 0 {
+			key = fmt.Sprintf("traffic:%d", cd.TrafficID)
+		}
+		if cd.TrafficID == 0 && cd.InboundID > 0 && cd.UUID != "" {
+			key = fmt.Sprintf("tuic:%d:%s", cd.InboundID, cd.UUID)
+		}
+		traffic := clientTrafficMap[key]
+		if traffic == nil {
+			traffic = &xray.ClientTraffic{Email: cd.Email, TuicTrafficID: cd.TrafficID, TuicUUID: cd.UUID, TuicInboundId: cd.InboundID}
+			clientTrafficMap[key] = traffic
+		}
+		traffic.Up += cd.Up
+		traffic.Down += cd.Down
+	}
+	for _, email := range onlineEmails {
+		if _, exists := clientTrafficMap[email]; !exists {
+			clientTrafficMap[email] = &xray.ClientTraffic{
+				Email: email,
+				Up:    0,
+				Down:  0,
+			}
+		}
+	}
+
+	clientTraffics := make([]*xray.ClientTraffic, 0, len(clientTrafficMap))
+	for _, ct := range clientTrafficMap {
+		clientTraffics = append(clientTraffics, ct)
+	}
+	return clientTraffics
+}

+ 182 - 0
internal/web/job/tuic_job_test.go

@@ -0,0 +1,182 @@
+package job
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"net"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func generateTestCertForJob(t *testing.T) (certPEM, keyPEM []byte) {
+	t.Helper()
+	priv, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("rsa.GenerateKey: %v", err)
+	}
+	template := x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject:      pkix.Name{CommonName: "localhost"},
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(time.Hour),
+		IPAddresses:  []net.IP{net.ParseIP("127.0.0.1")},
+	}
+	der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+	if err != nil {
+		t.Fatalf("CreateCertificate: %v", err)
+	}
+	certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+	return
+}
+
+func TestTuicJob_TrafficAccounting(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "tuic_job.db")); err != nil {
+		t.Fatalf("database.InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	certPEM, keyPEM := generateTestCertForJob(t)
+
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	_ = pc.Close()
+
+	email := "[email protected]"
+	settings := fmt.Sprintf(`{
+		"certificate": %q,
+		"private_key": %q,
+		"congestion_control": "bbr",
+		"alpn": ["h3"],
+		"udp_relay_mode": "native",
+		"zero_rtt_handshake": false,
+		"clients": [
+			{
+				"uuid": "a0000000-0000-0000-0000-000000000001",
+				"password": "password123",
+				"email": %q,
+				"enable": true
+			}
+		]
+	}`, string(certPEM), string(keyPEM), email)
+
+	inbound := &model.Inbound{
+		Id:       42,
+		Tag:      "tuic-in-42",
+		Protocol: model.TUIC,
+		Listen:   "127.0.0.1",
+		Port:     port,
+		Enable:   true,
+		Settings: settings,
+	}
+	if err := database.GetDB().Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound failed: %v", err)
+	}
+
+	clientTraffic := &xray.ClientTraffic{
+		InboundId: inbound.Id,
+		Email:     email,
+		Up:        0,
+		Down:      0,
+		Enable:    true,
+	}
+	if err := database.GetDB().Create(clientTraffic).Error; err != nil {
+		t.Fatalf("create clientTraffic failed: %v", err)
+	}
+
+	mgr := tuic.GetManager()
+	t.Cleanup(mgr.StopAll)
+
+	job := NewTuicJob()
+
+	// Initial run reconciles desired instances and starts the server
+	job.Run()
+
+	// Add test traffic to the running client
+	const wantUp = int64(1024)
+	const wantDown = int64(2048)
+	if !mgr.AddTestTraffic(inbound.Id, email, wantUp, wantDown) {
+		t.Fatalf("failed to add test traffic for %s on inbound %d", email, inbound.Id)
+	}
+
+	// Second run collects and writes traffic to database
+	job.Run()
+
+	// Verify client traffic in database
+	var dbClient xray.ClientTraffic
+	if err := database.GetDB().Where("inbound_id = ? AND email = ?", inbound.Id, email).First(&dbClient).Error; err != nil {
+		t.Fatalf("find client traffic in DB failed: %v", err)
+	}
+	if dbClient.Up != wantUp || dbClient.Down != wantDown {
+		t.Fatalf("client traffic mismatch: got up=%d down=%d, want up=%d down=%d", dbClient.Up, dbClient.Down, wantUp, wantDown)
+	}
+
+	// Verify inbound total traffic in database: TuicJob leaves inbound total
+	// accounting to xray_traffic_job (metered on the SOCKS relay tag, matching mtproto),
+	// preventing double-counting.
+	var dbInbound model.Inbound
+	if err := database.GetDB().First(&dbInbound, inbound.Id).Error; err != nil {
+		t.Fatalf("find inbound in DB failed: %v", err)
+	}
+	if dbInbound.Up != 0 || dbInbound.Down != 0 {
+		t.Fatalf("expected inbound traffic to remain 0 in TuicJob (metered by Xray bridge), got up=%d down=%d", dbInbound.Up, dbInbound.Down)
+	}
+}
+
+func TestAggregateTuicClientTrafficSumsAcrossInbounds(t *testing.T) {
+	got := aggregateTuicClientTraffic([]tuic.ClientTrafficDelta{
+		{Email: "[email protected]", Up: 100, Down: 200},
+		{Email: "[email protected]", Up: 300, Down: 400},
+		{Email: "[email protected]", Up: 5, Down: 6},
+	}, []string{"[email protected]", "[email protected]"})
+	byEmail := make(map[string]struct{ up, down int64 }, len(got))
+	for _, traffic := range got {
+		byEmail[traffic.Email] = struct{ up, down int64 }{traffic.Up, traffic.Down}
+	}
+	if shared := byEmail["[email protected]"]; shared.up != 400 || shared.down != 600 {
+		t.Fatalf("shared client traffic = %+v, want (400, 600)", shared)
+	}
+	if other := byEmail["[email protected]"]; other.up != 5 || other.down != 6 {
+		t.Fatalf("other client traffic = %+v, want (5, 6)", other)
+	}
+	if online, ok := byEmail["[email protected]"]; !ok || online.up != 0 || online.down != 0 {
+		t.Fatalf("online-only client traffic = %+v, present=%v", online, ok)
+	}
+	if len(got) != 3 {
+		t.Fatalf("got %d aggregated clients, want 3", len(got))
+	}
+}
+
+func TestAggregateTuicClientTrafficPreservesStableIdentityAcrossEmailRename(t *testing.T) {
+	const (
+		inboundID  = 82
+		clientUUID = "a0000000-0000-0000-0000-000000000082"
+	)
+	got := aggregateTuicClientTraffic([]tuic.ClientTrafficDelta{
+		{Email: "[email protected]", UUID: clientUUID, InboundID: inboundID, Up: 10, Down: 20},
+		{Email: "[email protected]", UUID: clientUUID, InboundID: inboundID, Up: 30, Down: 40},
+	}, nil)
+	if len(got) != 1 {
+		t.Fatalf("aggregate returned %d records, want 1", len(got))
+	}
+	if got[0].Email != "[email protected]" || got[0].TuicUUID != clientUUID || got[0].TuicInboundId != inboundID {
+		t.Fatalf("aggregate lost retired TUIC identity: %+v", got[0])
+	}
+	if got[0].Up != 40 || got[0].Down != 60 {
+		t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
+	}
+}

+ 135 - 0
internal/web/job/tuic_journal.go

@@ -0,0 +1,135 @@
+package job
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"sync"
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+)
+
+var tuicJournalMu sync.Mutex
+
+type tuicTrafficBatch struct {
+	ID     string                    `json:"id"`
+	Deltas []tuic.ClientTrafficDelta `json:"deltas"`
+}
+
+func tuicJournalDir() string { return filepath.Join(config.GetDBFolderPath(), "tuic-traffic-journal") }
+
+func syncJournalDir(dir string) error {
+	if runtime.GOOS == "windows" {
+		return nil
+	}
+	file, err := os.Open(dir)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+	return file.Sync()
+}
+
+func storeTuicBatch(batch tuicTrafficBatch) (string, error) {
+	dir := tuicJournalDir()
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return "", err
+	}
+	if err := syncJournalDir(filepath.Dir(dir)); err != nil {
+		return "", err
+	}
+	data, err := json.Marshal(batch)
+	if err != nil {
+		return "", err
+	}
+	file, err := os.CreateTemp(dir, ".pending-")
+	if err != nil {
+		return "", err
+	}
+	tmp := file.Name()
+	defer os.Remove(tmp)
+	if _, err := file.Write(data); err != nil {
+		file.Close()
+		return "", err
+	}
+	if err := file.Sync(); err != nil {
+		file.Close()
+		return "", err
+	}
+	if err := file.Close(); err != nil {
+		return "", err
+	}
+	path := filepath.Join(dir, batch.ID+".json")
+	if err := os.Rename(tmp, path); err != nil {
+		return "", err
+	}
+	return path, syncJournalDir(dir)
+}
+
+func (j *TuicJob) replayTuicJournal() error {
+	entries, err := os.ReadDir(tuicJournalDir())
+	if os.IsNotExist(err) {
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+	for _, entry := range entries {
+		if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
+			continue
+		}
+		path := filepath.Join(tuicJournalDir(), entry.Name())
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return err
+		}
+		var batch tuicTrafficBatch
+		if err := json.Unmarshal(data, &batch); err != nil {
+			return fmt.Errorf("TUIC journal %s: %w", entry.Name(), err)
+		}
+		if batch.ID+".json" != entry.Name() {
+			return fmt.Errorf("TUIC journal batch ID mismatch")
+		}
+		if err := j.inboundService.AddTuicTrafficBatch(batch.ID, aggregateTuicClientTraffic(batch.Deltas, nil)); err != nil {
+			return err
+		}
+		if err := os.Remove(path); err != nil {
+			return err
+		}
+		if err := syncJournalDir(tuicJournalDir()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (j *TuicJob) flushTuicJournal() error {
+	tuicJournalMu.Lock()
+	defer tuicJournalMu.Unlock()
+	manager := tuic.GetManager()
+	_, deltas := manager.CollectAllTraffic()
+	if len(deltas) > 0 {
+		if path, err := storeTuicBatch(tuicTrafficBatch{ID: uuid.NewString(), Deltas: deltas}); err != nil {
+			if path == "" {
+				manager.RequeueClientTraffic(deltas)
+			}
+			return fmt.Errorf("persist TUIC shutdown journal: %w", err)
+		}
+	}
+	var err error
+	for attempt := 0; attempt < 3; attempt++ {
+		if err = j.replayTuicJournal(); err == nil {
+			return nil
+		}
+		if attempt < 2 {
+			time.Sleep(100 * time.Millisecond)
+		}
+	}
+	return fmt.Errorf("TUIC traffic retained in durable journal: %w", err)
+}

+ 67 - 0
internal/web/job/tuic_journal_test.go

@@ -0,0 +1,67 @@
+package job
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestTuicShutdownJournalSurvivesDatabaseFailureAndReplaysOnce(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dir)
+	dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
+	db := database.GetDB()
+	row := xray.ClientTraffic{Email: "journal@x", Enable: true}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	batch := tuicTrafficBatch{ID: "shutdown-batch", Deltas: []tuic.ClientTrafficDelta{{Email: row.Email, TrafficID: row.Id, Up: 123, Down: 456}}}
+	path, err := storeTuicBatch(batch)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Exec(`CREATE TRIGGER fail_tuic_write BEFORE UPDATE ON client_traffics BEGIN SELECT RAISE(FAIL, 'disk fault'); END`).Error; err != nil {
+		t.Fatal(err)
+	}
+	job := NewTuicJob()
+	if err := job.FlushStoppedTraffic(); err == nil {
+		t.Fatal("failed traffic update must return an error")
+	}
+	if _, err := os.Stat(path); err != nil {
+		t.Fatalf("durable recovery file missing: %v", err)
+	}
+	var receipts int64
+	if err := db.Table("tuic_traffic_receipts").Count(&receipts).Error; err == nil && receipts != 0 {
+		t.Fatal("failed transaction committed a receipt")
+	}
+	if err := db.Exec("DROP TRIGGER fail_tuic_write").Error; err != nil {
+		t.Fatal(err)
+	}
+	// A new job has no knowledge of the previous process's in-memory deltas.
+	recovered := NewTuicJob()
+	if err := recovered.replayTuicJournal(); err != nil {
+		t.Fatal(err)
+	}
+	// Recreate a stale file, as if file removal was lost after DB commit.
+	if _, err := storeTuicBatch(batch); err != nil {
+		t.Fatal(err)
+	}
+	if err := recovered.replayTuicJournal(); err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Where("id = ?", row.Id).First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.Up != 123 || row.Down != 456 {
+		t.Fatalf("replay billed %d/%d", row.Up, row.Down)
+	}
+	entries, err := os.ReadDir(tuicJournalDir())
+	if err != nil || len(entries) != 0 {
+		t.Fatalf("journal not drained: %v %v", entries, err)
+	}
+}

+ 1 - 1
internal/web/network/serve_test.go

@@ -63,7 +63,7 @@ func TestProductionHTTPServersUseServeHTTPWrapper(t *testing.T) {
 			return walkErr
 		}
 		if entry.IsDir() {
-			if entry.Name() == ".git" || entry.Name() == "vendor" || entry.Name() == "node_modules" {
+			if entry.Name() == ".git" || entry.Name() == "vendor" || entry.Name() == "node_modules" || entry.Name() == "third_party" {
 				return filepath.SkipDir
 			}
 			return nil

+ 25 - 1
internal/web/runtime/local.go

@@ -90,7 +90,11 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
 		if !ok {
 			return nil
 		}
-		return tuic.GetManager().Ensure(inst)
+		err := tuic.GetManager().Ensure(inst)
+		if l.deps.SetNeedRestart != nil {
+			l.deps.SetNeedRestart()
+		}
+		return err
 	}
 	body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", "  ")
 	if err != nil {
@@ -118,6 +122,9 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
 	}
 	if ib.Protocol == model.TUIC {
 		tuic.GetManager().Remove(ib.Id)
+		if l.deps.SetNeedRestart != nil {
+			l.deps.SetNeedRestart()
+		}
 		return nil
 	}
 	return l.withAPI(func(api *xray.XrayAPI) error {
@@ -228,6 +235,9 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
 func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
 	if oldIb.Protocol == model.TUIC && newIb.Protocol != model.TUIC {
 		tuic.GetManager().Remove(oldIb.Id)
+		if l.deps.SetNeedRestart != nil {
+			l.deps.SetNeedRestart()
+		}
 		if !newIb.Enable {
 			return nil
 		}
@@ -235,11 +245,25 @@ func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbou
 	}
 	if oldIb.Protocol != model.TUIC {
 		_ = l.DelInbound(ctx, oldIb)
+		if l.deps.SetNeedRestart != nil {
+			l.deps.SetNeedRestart()
+		}
+	}
+	if oldIb.Protocol == model.TUIC && newIb.Protocol == model.TUIC && oldIb.Enable && newIb.Enable && oldIb.Tag != newIb.Tag && l.deps.SetNeedRestart != nil {
+		l.deps.SetNeedRestart()
 	}
 	if !newIb.Enable {
 		tuic.GetManager().Remove(newIb.Id)
+		if oldIb.Enable && l.deps.SetNeedRestart != nil {
+			l.deps.SetNeedRestart()
+		}
 		return nil
 	}
+	if !oldIb.Enable && newIb.Enable {
+		if l.deps.SetNeedRestart != nil {
+			l.deps.SetNeedRestart()
+		}
+	}
 	inst, ok := tuic.InstanceFromInbound(newIb)
 	if !ok {
 		tuic.GetManager().Remove(newIb.Id)

+ 38 - 0
internal/web/runtime/local_tuic_test.go

@@ -0,0 +1,38 @@
+package runtime
+
+import (
+	"context"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
+	resyncs := 0
+	local := NewLocal(LocalDeps{SetNeedRestart: func() { resyncs++ }})
+	oldInbound := &model.Inbound{Id: 940001, Tag: "old-tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[]}`}
+	newInbound := *oldInbound
+	newInbound.Tag = "new-tuic-tag"
+
+	if err := local.updateTuicInbound(context.Background(), oldInbound, &newInbound); err != nil {
+		t.Fatalf("updateTuicInbound: %v", err)
+	}
+	if resyncs != 1 {
+		t.Fatalf("Xray bridge resync count = %d, want 1", resyncs)
+	}
+}
+
+func TestUpdateTuicInboundDoesNotResyncForProfileOnlyChange(t *testing.T) {
+	resyncs := 0
+	local := NewLocal(LocalDeps{SetNeedRestart: func() { resyncs++ }})
+	oldInbound := &model.Inbound{Id: 940002, Tag: "tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[],"congestion_control":"bbr"}`}
+	newInbound := *oldInbound
+	newInbound.Settings = `{"clients":[],"congestion_control":"cubic","log_level":"debug"}`
+
+	if err := local.updateTuicInbound(context.Background(), oldInbound, &newInbound); err != nil {
+		t.Fatalf("updateTuicInbound: %v", err)
+	}
+	if resyncs != 0 {
+		t.Fatalf("profile-only update triggered %d Xray restarts, want 0", resyncs)
+	}
+}

+ 5 - 1
internal/web/runtime/remote.go

@@ -713,7 +713,11 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
 }
 
 func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
-	_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
+	id, err := r.resolveRemoteID(ctx, ib.Tag)
+	if err != nil {
+		return fmt.Errorf("remote ResetInboundTraffic: resolve tag %q: %w", ib.Tag, err)
+	}
+	_, err = r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", id), nil)
 	return err
 }
 

+ 49 - 0
internal/web/runtime/remote_reset_test.go

@@ -7,6 +7,8 @@ import (
 	"net/http/httptest"
 	"slices"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 // The master replays a node's reset backlog through the node's bulk endpoint.
@@ -31,3 +33,50 @@ func TestRemoteResetClientTrafficsPostsEmailsToBulkEndpoint(t *testing.T) {
 		t.Fatalf("node got %s %v, want /panel/api/clients/bulkResetTraffic [a@x b@x]", path, body.Emails)
 	}
 }
+
+// A central inbound id need not match the node's id, so the reset must target
+// the node-side id resolved from the tag, never ib.Id.
+func TestRemoteResetInboundTrafficUsesNodeInboundID(t *testing.T) {
+	var method, path string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		method, path = req.Method, req.URL.Path
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"msg":"ok"}`))
+	}))
+	t.Cleanup(srv.Close)
+
+	r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
+	r.cacheSet("n1-in-443", 7)
+	ib := &model.Inbound{Id: 42, Tag: "n1-in-443"}
+	if err := r.ResetInboundTraffic(context.Background(), ib); err != nil {
+		t.Fatalf("ResetInboundTraffic: %v", err)
+	}
+	if method != http.MethodPost || path != "/panel/api/inbounds/7/resetTraffic" {
+		t.Fatalf("node got %s %s, want POST /panel/api/inbounds/7/resetTraffic", method, path)
+	}
+}
+
+// An unresolvable tag must fail before posting, so a reset never lands on an
+// unrelated node inbound that happens to share the central id.
+func TestRemoteResetInboundTrafficUnknownTagErrors(t *testing.T) {
+	var resetPosted bool
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		if req.URL.Path == "/panel/api/inbounds/list" {
+			_, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":[]}`))
+			return
+		}
+		resetPosted = true
+		_, _ = w.Write([]byte(`{"success":true,"msg":"ok"}`))
+	}))
+	t.Cleanup(srv.Close)
+
+	r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
+	ib := &model.Inbound{Id: 42, Tag: "n1-in-443"}
+	if err := r.ResetInboundTraffic(context.Background(), ib); err == nil {
+		t.Fatal("ResetInboundTraffic error = nil, want unknown-tag error")
+	}
+	if resetPosted {
+		t.Fatal("reset request posted to node despite an unresolved tag")
+	}
+}

+ 4 - 4
internal/web/service/client_inbound_apply.go

@@ -433,7 +433,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 
 	var portCtx portConflictContext
 	if oldInbound.Protocol == model.AmneziaWG {
-		portCtx, err = inboundSvc.loadPortConflictContext(database.GetDB())
+		portCtx, err = inboundSvc.loadPortConflictContext(database.GetDB(), oldInbound.NodeID)
 		if err != nil {
 			return false, err
 		}
@@ -547,7 +547,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			}
 		}
 		if oldInbound.Protocol == model.AmneziaWG {
-			txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx)
+			txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx, oldInbound.NodeID)
 			if pErr != nil {
 				return pErr
 			}
@@ -784,7 +784,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		}
 	}
 	if oldInbound.Protocol == model.AmneziaWG {
-		portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB())
+		portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB(), oldInbound.NodeID)
 		if err != nil {
 			return false, err
 		}
@@ -921,7 +921,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		// Same re-check-inside-the-writer rule as AddInboundClient (#6225):
 		// the pre-tx pass can race a concurrent writer on another inbound.
 		if oldInbound.Protocol == model.AmneziaWG {
-			txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx)
+			txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx, oldInbound.NodeID)
 			if pErr != nil {
 				return pErr
 			}

+ 87 - 1
internal/web/service/client_link.go

@@ -1,8 +1,11 @@
 package service
 
 import (
+	"fmt"
 	"strings"
 
+	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 
@@ -102,6 +105,10 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 		tx = database.GetDB()
 	}
 
+	if err := s.validateTuicIdentities(tx, inboundId, clients, detachEmails, prune); err != nil {
+		return err
+	}
+
 	emails := make([]string, 0, len(clients))
 	seen := make(map[string]struct{}, len(clients))
 	for i := range clients {
@@ -215,7 +222,42 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 		wantedIds = append(wantedIds, id)
 	}
 
-	return s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
+	if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
+		return err
+	}
+
+	affected := map[int]struct{}{inboundId: {}}
+	for _, ids := range chunkInts(wantedIds, sqlInChunk) {
+		var inboundIDs []int
+		if err := tx.Model(&model.ClientInbound{}).Distinct("inbound_id").Where("client_id IN ?", ids).Pluck("inbound_id", &inboundIDs).Error; err != nil {
+			return err
+		}
+		for _, id := range inboundIDs {
+			affected[id] = struct{}{}
+		}
+	}
+	affectedIDs := make([]int, 0, len(affected))
+	for id := range affected {
+		affectedIDs = append(affectedIDs, id)
+	}
+	for _, ids := range chunkInts(affectedIDs, sqlInChunk) {
+		var duplicates int64
+		err := tx.Raw(`SELECT COUNT(*) FROM (
+            SELECT ci.inbound_id, LOWER(c.uuid) FROM clients c
+            JOIN client_inbounds ci ON ci.client_id = c.id
+            JOIN inbounds i ON i.id = ci.inbound_id
+            WHERE i.protocol = ? AND c.uuid <> '' AND ci.inbound_id IN ?
+            GROUP BY ci.inbound_id, LOWER(c.uuid) HAVING COUNT(*) > 1
+        ) AS duplicate_uuids`, model.TUIC, ids).Scan(&duplicates).Error
+		if err != nil {
+			return err
+		}
+		if duplicates > 0 {
+			return fmt.Errorf("TUIC: duplicate client UUID within inbound")
+		}
+	}
+
+	return nil
 }
 
 // reconcileInboundLinks writes only the client_inbounds rows that differ. prune
@@ -373,3 +415,47 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
 	}
 	return out, nil
 }
+
+func (s *ClientService) validateTuicIdentities(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
+	var inbound model.Inbound
+	if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
+		return err
+	}
+	if inbound.Protocol != model.TUIC {
+		return nil
+	}
+	candidates := append([]model.Client(nil), changed...)
+	if !prune {
+		current, err := s.ListForInbound(tx, inboundID)
+		if err != nil {
+			return err
+		}
+		excluded := make(map[string]bool)
+		for _, client := range changed {
+			excluded[client.Email] = true
+		}
+		for _, email := range detached {
+			excluded[email] = true
+		}
+		for _, client := range current {
+			if !excluded[client.Email] {
+				candidates = append(candidates, client)
+			}
+		}
+	}
+	seen := make(map[uuid.UUID]string)
+	for _, client := range candidates {
+		if client.ID == "" {
+			continue
+		}
+		id, err := uuid.Parse(client.ID)
+		if err != nil {
+			return fmt.Errorf("TUIC: invalid client UUID")
+		}
+		if email, exists := seen[id]; exists && email != client.Email {
+			return fmt.Errorf("TUIC: duplicate client UUID within inbound")
+		}
+		seen[id] = client.Email
+	}
+	return nil
+}

+ 32 - 0
internal/web/service/client_link_postgres_test.go

@@ -0,0 +1,32 @@
+package service
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// PostgreSQL before 16 rejects a FROM subquery without an alias, so the TUIC
+// duplicate-UUID guard must not break client sync for every protocol there.
+func TestSyncInboundClientsOnPostgres(t *testing.T) {
+	db := durablePostgresDB(t)
+	suffix := time.Now().UnixNano()
+	inbound := durableTestInbound(nil, fmt.Sprintf("pg-sync-%d", suffix), 20000+int(suffix%20000))
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	email := fmt.Sprintf("pg-sync-%d@x", suffix)
+	t.Cleanup(func() {
+		_ = db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error
+		_ = db.Where("email = ?", email).Delete(&model.ClientRecord{}).Error
+		_ = db.Delete(&model.Inbound{}, inbound.Id).Error
+	})
+
+	clients := []model.Client{{ID: "8a9c1b2e-1111-4c3d-9e8f-000000000001", Email: email, Enable: true}}
+	if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
+		t.Fatalf("SyncInbound on PostgreSQL: %v", err)
+	}
+}

+ 62 - 11
internal/web/service/client_paging.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -113,13 +114,47 @@ const (
 	sqlClientEnabled = "COALESCE(c.enable, FALSE)"
 )
 
-const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
-	OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
-	OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
-	OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
-	OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
-	OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
-	OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
+// clientSearchCols are the text columns the search box matches.
+var clientSearchCols = []string{
+	"c.email", "COALESCE(c.sub_id, '')", "COALESCE(c.comment, '')",
+	"COALESCE(c.uuid, '')", "COALESCE(c.password, '')", "COALESCE(c.auth, '')",
+}
+
+// caseVariants returns s lower-cased, as typed, upper-cased and title-cased.
+// SQLite's LOWER() and LIKE fold ASCII only, so non-ASCII text is matched
+// against these spellings instead of relying on the database to fold it.
+func caseVariants(s string) []string {
+	title := s
+	if r := []rune(strings.ToLower(s)); len(r) > 0 {
+		title = strings.ToUpper(string(r[:1])) + string(r[1:])
+	}
+	out := make([]string, 0, 4)
+	for _, v := range []string{strings.ToLower(s), s, strings.ToUpper(s), title} {
+		if !slices.Contains(out, v) {
+			out = append(out, v)
+		}
+	}
+	return out
+}
+
+// clientSearchCond builds the search predicate for the given needle variants
+// (lowered first) and returns its arguments.
+func clientSearchCond(variants []string) (string, []any) {
+	var parts []string
+	var args []any
+	like := func(v string) string { return "%" + escapeLikeLiteral(v) + "%" }
+	for _, col := range clientSearchCols {
+		parts = append(parts, "LOWER("+col+") LIKE ? ESCAPE '\\'")
+		args = append(args, like(variants[0]))
+		for _, v := range variants {
+			parts = append(parts, col+" LIKE ? ESCAPE '\\'")
+			args = append(args, like(v))
+		}
+	}
+	parts = append(parts, "(COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\\')")
+	args = append(args, like(variants[0]))
+	return "(" + strings.Join(parts, " OR ") + ")", args
+}
 
 // clientQuery builds the statements behind the clients page: a clients row
 // joined to its traffic counters, plus the expressions every bucket predicate
@@ -213,9 +248,9 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
 		tx = tx.Where(cond, args...)
 	}
 
-	if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
-		pattern := "%" + escapeLikeLiteral(needle) + "%"
-		where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
+	if needle := strings.TrimSpace(params.Search); needle != "" {
+		cond, args := clientSearchCond(caseVariants(needle))
+		where(cond, args...)
 	}
 	if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
 		where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
@@ -264,7 +299,8 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
 		where("TRIM(COALESCE(c.comment, '')) = ''")
 	}
 	if groups := parseCSVStrings(params.Group); len(groups) > 0 {
-		where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
+		// The raw names cover non-ASCII capitals, which SQLite's LOWER() leaves alone.
+		where("(LOWER(TRIM(COALESCE(c.group_name, ''))) IN ? OR TRIM(COALESCE(c.group_name, '')) IN ?)", groups, groupVariants(params.Group))
 	}
 	return tx, narrowed
 }
@@ -662,6 +698,21 @@ func parseCSVStrings(raw string) []string {
 	return out
 }
 
+// groupVariants is every case spelling of each requested group name.
+func groupVariants(raw string) []string {
+	var out []string
+	for _, p := range strings.Split(raw, ",") {
+		if p = strings.TrimSpace(p); p != "" {
+			for _, v := range caseVariants(p) {
+				if !slices.Contains(out, v) {
+					out = append(out, v)
+				}
+			}
+		}
+	}
+	return out
+}
+
 // parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
 // non-positive entries are silently dropped.
 func parseCSVInts(raw string) []int {

+ 33 - 0
internal/web/service/client_paging_test.go

@@ -635,3 +635,36 @@ func TestListPagedEmptyPanel(t *testing.T) {
 		t.Fatal("groups = nil, want an empty list so the filter drawer renders")
 	}
 }
+
+// SQLite's LOWER() folds ASCII only, so non-ASCII capitals must still match.
+func TestListPagedNonASCIICase(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	rec := model.ClientRecord{Email: "lima@x", Comment: "Привет", Group: "Тест", Enable: true}
+	if err := database.GetDB().Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	for name, params := range map[string]ClientPageParams{
+		"group":             {PageSize: 50, Group: "Тест"},
+		"group other case":  {PageSize: 50, Group: "ТЕСТ"},
+		"search":            {PageSize: 50, Search: "Привет"},
+		"search lower case": {PageSize: 50, Search: "привет"},
+		"search upper case": {PageSize: 50, Search: "ПРИВЕТ"},
+	} {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, params)
+		if err != nil {
+			t.Fatalf("%s: ListPaged: %v", name, err)
+		}
+		if got := pagedEmails(resp.Items); !slices.Equal(got, []string{"lima@x"}) {
+			t.Fatalf("%s: emails = %v, want [lima@x]", name, got)
+		}
+	}
+}
+
+func TestCaseVariants(t *testing.T) {
+	if got, want := caseVariants("пРИвет"), []string{"привет", "пРИвет", "ПРИВЕТ", "Привет"}; !slices.Equal(got, want) {
+		t.Fatalf("caseVariants = %v, want %v", got, want)
+	}
+	if got := caseVariants("abc"); !slices.Equal(got, []string{"abc", "ABC", "Abc"}) {
+		t.Fatalf("ASCII variants = %v", got)
+	}
+}

+ 82 - 14
internal/web/service/inbound.go

@@ -26,6 +26,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/gorm"
 	"gorm.io/gorm/clause"
@@ -512,15 +513,22 @@ func inboundTuicServer(protocol string, settings string) *tuic.TuicServerSetting
 	if protocol != string(model.TUIC) || strings.TrimSpace(settings) == "" {
 		return nil
 	}
-	var parsed struct {
-		Server *tuic.TuicServerSettings `json:"server"`
-	}
-	if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
+	inst, ok := tuic.InstanceFromInbound(&model.Inbound{Protocol: model.TUIC, Settings: settings})
+	if !ok {
 		return nil
 	}
-	redacted := *parsed.Server
-	redacted.PrivateKey = ""
-	return &redacted
+	return &tuic.TuicServerSettings{
+		Certificate:           inst.Certificate,
+		CongestionControl:     inst.CongestionControl,
+		ALPN:                  inst.ALPN,
+		UDPRelayMode:          inst.UDPRelayMode,
+		ZeroRTTHandshake:      inst.ZeroRTTHandshake,
+		LogLevel:              inst.LogLevel,
+		MaxIdleTime:           inst.MaxIdleTime,
+		AuthenticationTimeout: inst.AuthenticationTimeout,
+		MaxUdpRelayPacketSize: inst.MaxUdpRelayPacketSize,
+		SNI:                   inst.SNI,
+	}
 }
 
 // inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
@@ -1086,6 +1094,21 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 	// Prefer the already-stored port (carried across edits), then any value the
 	// client sent, then allocate a fresh one.
 	port := parseRouteXrayPort(oldSettings)
+	if inbound.NodeID != nil {
+		// The port is free or taken on the node's host, not here: the node's own
+		// panel allocates it, and node sync brings its choice back as oldSettings.
+		if port <= 0 {
+			delete(parsed, "routeXrayPort")
+		} else {
+			parsed["routeXrayPort"] = port
+		}
+		bs, err := json.MarshalIndent(parsed, "", "  ")
+		if err != nil {
+			return common.NewError("mtproto: could not persist the Xray egress port:", err)
+		}
+		inbound.Settings = string(bs)
+		return nil
+	}
 	if port <= 0 {
 		port = settingsRouteXrayPort(parsed)
 	}
@@ -1114,6 +1137,9 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	inbound.Id = 0
+	if err := normalizeTuicSettings(inbound); err != nil {
+		return inbound, false, err
+	}
 	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
@@ -1136,8 +1162,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	if err := s.normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		return inbound, false, err
 	}
-	if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
-		return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
+	if inbound.NodeID != nil {
+		if err := checkNodeCanHostProtocol(database.GetDB(), *inbound.NodeID, inbound.Protocol); err != nil {
+			return inbound, false, err
+		}
 	}
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 	if err := normalizeInboundShareAddressStrict(inbound); err != nil {
@@ -1293,6 +1321,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 				return aErr
 			}
 		}
+		if inbound.NodeID == nil && inbound.Protocol == model.TUIC {
+			if self := tuicSocksSelfConflict(inbound, inbound.Id); self != "" {
+				return common.NewError(self)
+			}
+			conflict, cErr := checkTuicSocksRelayCollision(tx, inbound.Id)
+			if cErr != nil {
+				return cErr
+			}
+			if conflict != nil {
+				return common.NewError(conflict.String())
+			}
+			conflict, cErr = checkTuicSocksReverseConflict(tx, inbound.Id)
+			if cErr != nil {
+				return cErr
+			}
+			if conflict != nil {
+				return common.NewError(conflict.String())
+			}
+		}
 		// Emails seeded here (import's ClientStats, e.g. the controller's forced
 		// Enable=true on every imported stat row) are authoritative for this call
 		// and must not be clobbered by the AddClientStat loop below, which derives
@@ -1752,6 +1799,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err != nil {
 		return inbound, false, err
 	}
+	// Restore the stored NodeID before any host-scoped check so a node inbound
+	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
+	inbound.NodeID = oldInbound.NodeID
+	if err := normalizeTuicSettings(inbound); err != nil {
+		return inbound, false, err
+	}
 	if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil {
 		return inbound, false, err
 	}
@@ -1766,13 +1819,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 		}
 	}
-	// Restore the stored NodeID before the port-conflict check so a node inbound
-	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
-	inbound.NodeID = oldInbound.NodeID
 	// The node assignment is the stored one, so only a protocol change can
 	// introduce one; a row adopted from a node keeps the protocol it arrived with.
-	if inbound.NodeID != nil && inbound.Protocol != oldInbound.Protocol && !isNodeEligibleProtocol(inbound.Protocol) {
-		return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
+	if inbound.NodeID != nil && inbound.Protocol != oldInbound.Protocol {
+		if err := checkNodeCanHostProtocol(database.GetDB(), *inbound.NodeID, inbound.Protocol); err != nil {
+			return inbound, false, err
+		}
 	}
 
 	// Capture the pre-edit protocol and routing state before oldInbound is
@@ -2117,6 +2169,16 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
 		return nil, err
 	}
 
+	trafficIDs := make(map[string]int)
+	if inbound.Protocol == model.TUIC {
+		var rows []xray.ClientTraffic
+		if err := tx.Select("id", "email").Where("email IN ?", emails).Find(&rows).Error; err != nil {
+			return nil, err
+		}
+		for _, row := range rows {
+			trafficIDs[row.Email] = row.Id
+		}
+	}
 	finalClients := make([]any, 0, len(clients))
 	for _, client := range clients {
 		c, ok := client.(map[string]any)
@@ -2130,6 +2192,12 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
 		if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
 			continue
 		}
+		if inbound.Protocol == model.TUIC {
+			delete(c, "traffic_id")
+			if id := trafficIDs[email]; id > 0 {
+				c["traffic_id"] = id
+			}
+		}
 		finalClients = append(finalClients, c)
 	}
 	settings["clients"] = finalClients

+ 31 - 14
internal/web/service/inbound_amneziawg.go

@@ -13,6 +13,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 
@@ -262,7 +263,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
 		}
 	}
 
-	portCtx, err := s.loadPortConflictContext(database.GetDB())
+	portCtx, err := s.loadPortConflictContext(database.GetDB(), inbound.NodeID)
 	if err != nil {
 		return err
 	}
@@ -303,23 +304,31 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
 	return nil
 }
 
-// portConflictContext caches what checkForwardedPortsConflict needs — the panel's
-// own port and this host's enabled rows — so one save costs one query, not N.
+// portConflictContext caches what checkForwardedPortsConflict needs about the
+// host a forward listener binds on, so one save costs one query, not N.
 type portConflictContext struct {
 	webPort  int
 	inbounds []*model.Inbound
+	// onNode: the host is a node, whose web port and relay ports (derived from
+	// its own inbound ids) this panel does not know; the node re-checks both.
+	onNode bool
 }
 
-// loadPortConflictContext loads the panel's own port and every enabled inbound
-// hosted on THIS panel: a node-hosted one listens on that node's host, not here.
-func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) {
+// loadPortConflictContext loads every enabled inbound hosted where nodeID's rows
+// run -- this panel for nil, else that node -- plus this panel's own port.
+func (s *InboundService) loadPortConflictContext(db *gorm.DB, nodeID *int) (portConflictContext, error) {
 	var ctx portConflictContext
-	if webPort, err := (&SettingService{}).GetPort(); err == nil {
-		ctx.webPort = webPort
+	q := db.Model(model.Inbound{}).Where("enable = ?", true)
+	if nodeID != nil {
+		ctx.onNode = true
+		q = q.Where("node_id = ?", *nodeID)
+	} else {
+		if webPort, err := (&SettingService{}).GetPort(); err == nil {
+			ctx.webPort = webPort
+		}
+		q = q.Where("node_id IS NULL")
 	}
-	err := db.Model(model.Inbound{}).
-		Where("enable = ? AND node_id IS NULL", true).
-		Find(&ctx.inbounds).Error
+	err := q.Find(&ctx.inbounds).Error
 	return ctx, err
 }
 
@@ -340,7 +349,7 @@ func (s *InboundService) checkAmneziaWGForwardedPorts(db *gorm.DB, settings stri
 	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
 		return nil
 	}
-	ctx, err := s.loadPortConflictContext(db)
+	ctx, err := s.loadPortConflictContext(db, nil)
 	if err != nil {
 		return err
 	}
@@ -372,10 +381,18 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo
 			}
 			return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
 		}
-		if ib.Protocol != model.AmneziaWG {
+		if ctx.onNode {
+			continue
+		}
+		var socksPort int
+		switch ib.Protocol {
+		case model.AmneziaWG:
+			socksPort = amneziawgnet.SOCKSPortForInbound(ib.Id)
+		case model.TUIC:
+			socksPort = tuic.SOCKSPortForInbound(ib.Id)
+		default:
 			continue
 		}
-		socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id)
 		if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) {
 			name := ib.Remark
 			if name == "" {

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác