Sfoglia il codice sorgente

feat(tuic): implement native in-process Go TUIC v5 server (#6577)

* feat(tuic): implement native in-process Go TUIC v5 server

- Implement native TUIC v5 protocol server on pure Go using quic-go
- Bridge decrypted TCP/UDP traffic into Xray-core via loopback SOCKS5 inbound
- Support full Xray routing rules (geosite/geoip) and cascading outbounds
- Implement atomic per-client traffic accounting with TotalGB and ExpiryTime
- Add automatic legacy cleanup for older Rust tuic-server binaries, configs, and orphaned processes
- Eliminate external Rust tuic-server downloads from install/CI scripts

* fix(tuic): address traffic accounting, client reload, and socket lifecycle issues

* fix(service): update checkTuicSocksReverseConflict to use bindAddr for listenOverlaps

* fix(tuic): resolve traffic double-accounting, UDP fragmentation, and socket lifecycle issues

* feat(tuic): complete native Go integration and address audit findings

- Integrate an isolated QUIC fork pinned to a specific commit
- Preserve original QUIC dependencies for Xray, Hysteria and Gin
- Apply BBR, CUBIC and Reno to server connections and exported client profiles
- Bridge Xray BBR with correct monotonic time and congestion type conversions
- Handle congestion sender recreation after PMTU changes
- Update congestion control for new connections without restarting the listener
- Preserve existing connections and their selected congestion controller
- Apply per-inbound log levels through the shared panel logger
- Add lifecycle, authentication and TCP/UDP relay events without exposing secrets
- Rate-limit repeated authentication and relay warnings
- Support native and QUIC UDP relay modes on the same listener
- Recover UDP associations after relay worker failures
- Fix TCP relay cancellation, idle shutdown and half-close handling
- Close active sessions when client credentials are revoked or disabled
- Track traffic by immutable client statistics IDs across email and UUID changes
- Prevent ambiguous accounting and duplicate UUIDs within TUIC inbounds
- Persist pending traffic in a durable shutdown journal
- Replay journal batches transactionally without duplicate accounting
- Report server shutdown failures through the shared logger
- Preserve legacy flat and nested TUIC settings compatibility
- Normalize congestion controller values consistently across backend and frontend
- Preserve controller, UDP mode and SNI in client links and subscriptions
- Separate client profile options from server settings in the TUIC form
- Keep certificate path autofill explicit when changing client SNI
- Align UDP packet size validation with protocol limits
- Simplify and localize TUIC field hints and certificate autofill messages
- Add controller, TCP/UDP, logging and live settings update tests
- Add accounting identity, journal replay and shutdown regression tests
- Add relay recovery, session revocation and legacy frontend form tests

* fix(service): alias the TUIC duplicate-UUID subquery for PostgreSQL < 16

syncInboundClients runs a COUNT(*) FROM (subquery) for every client sync,
whatever the protocol. PostgreSQL before 16 rejects a FROM subquery with
no alias, so on the distro PostgreSQL install.sh provisions (14 on Ubuntu
22.04, 15 on Debian 12) every client add or edit failed with SQLSTATE
42601. Reproduced against postgres:15 with the new env-gated test.

* fix(database): create tuic_traffic_receipts through the model migration

AddTuicTrafficBatch issued CREATE TABLE IF NOT EXISTS at runtime, a schema
change outside db.go. The table was invisible to allModels and
migrationModels, so x-ui migrate-db dropped the receipts and a retained
journal could be counted twice after a SQLite to PostgreSQL move. It is
now a GORM model in both lists, and the insert uses OnConflict DoNothing.

* chore(tuic): skip the ICMP-dependent relay test on Windows, drop dead collectors

Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails
a read there and TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure
was red on every Windows run. Server.CollectTotalTraffic and
Manager.CollectTraffic had no caller.

* refactor(tuic): serve TUIC on apernet/quic-go instead of a personal fork

The native server depended on github.com/poise52/quic-go, a personal fork
of apernet/quic-go patched only to pick the congestion controller before
the handshake. That put a second QUIC/TLS stack in the binary that no
upstream security fix reaches. apernet/quic-go is already in the graph
through xray-core and exposes SetCongestionControl, so BBR is now installed
on each accepted connection with Xray's own congestion.UseBBR; the
cross-module BBR adapter is gone.

apernet ships New Reno as its only built-in sender, so a cubic setting is
served as new_reno server-side (clients still get cubic in their profile).
The test inspectors now read the sender under congestionMutex, which the
post-handshake install writes under.

Linux loopback, single stream through Xray: 2428 -> 3383 Mbit/s (bbr).

---------

Co-authored-by: Sanaei <[email protected]>
Egor 10 ore fa
parent
commit
0054e671f8
100 ha cambiato i file con 7930 aggiunte e 1455 eliminazioni
  1. 0 25
      .github/workflows/release.yml
  2. 0 21
      DockerInit.sh
  3. 6 7
      docs/architecture.md
  4. 1 1
      docs/content/docs/en/config/clients.mdx
  5. 1 1
      docs/content/docs/en/config/inbounds.mdx
  6. 8 11
      docs/content/docs/en/config/tuic.mdx
  7. 1 1
      docs/content/docs/fa/config/clients.mdx
  8. 1 1
      docs/content/docs/fa/config/inbounds.mdx
  9. 1 1
      docs/content/docs/ru/config/clients.mdx
  10. 1 1
      docs/content/docs/ru/config/inbounds.mdx
  11. 8 11
      docs/content/docs/ru/config/tuic.mdx
  12. 1 1
      docs/content/docs/zh/config/clients.mdx
  13. 1 1
      docs/content/docs/zh/config/inbounds.mdx
  14. 35 0
      frontend/src/lib/tuic.ts
  15. 7 1
      frontend/src/lib/xray/inbound-form-adapter.ts
  16. 5 3
      frontend/src/lib/xray/inbound-link.ts
  17. 1 16
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  18. 1 18
      frontend/src/pages/clients/ClientFormModal.tsx
  19. 2 1
      frontend/src/pages/clients/tuicConfig.ts
  20. 19 29
      frontend/src/pages/inbounds/form/protocols/tuic.tsx
  21. 25 5
      frontend/src/schemas/protocols/inbound/tuic.ts
  22. 42 0
      frontend/src/test/inbound-defaults.test.ts
  23. 35 0
      frontend/src/test/inbound-link.test.ts
  24. 48 0
      frontend/src/test/tuic-client-config.test.ts
  25. 80 0
      frontend/src/test/tuic-legacy-form.test.tsx
  26. 2 2
      go.mod
  27. 4 32
      install.sh
  28. 1 0
      internal/database/db.go
  29. 1 0
      internal/database/migrate_data.go
  30. 7 0
      internal/database/model/tuic_traffic_receipt.go
  31. 7 1
      internal/sub/service_tuic_test.go
  32. 220 0
      internal/tuic/auth.go
  33. 180 0
      internal/tuic/auth_test.go
  34. 0 94
      internal/tuic/config.go
  35. 0 87
      internal/tuic/config_test.go
  36. 112 81
      internal/tuic/manager.go
  37. 248 0
      internal/tuic/manager_live_traffic_test.go
  38. 140 0
      internal/tuic/manager_shutdown_test.go
  39. 352 48
      internal/tuic/manager_test.go
  40. 0 87
      internal/tuic/orphans_linux.go
  41. 0 5
      internal/tuic/orphans_other.go
  42. 0 292
      internal/tuic/process.go
  43. 0 7
      internal/tuic/process_other.go
  44. 0 67
      internal/tuic/process_windows.go
  45. 225 0
      internal/tuic/protocol.go
  46. 145 0
      internal/tuic/protocol_test.go
  47. 0 211
      internal/tuic/relay.go
  48. 263 0
      internal/tuic/relay_logging_test.go
  49. 243 0
      internal/tuic/relay_recovery_test.go
  50. 88 0
      internal/tuic/relay_shutdown_test.go
  51. 0 150
      internal/tuic/relay_test.go
  52. 47 0
      internal/tuic/revocation_snapshot_test.go
  53. 1069 0
      internal/tuic/server.go
  54. 129 0
      internal/tuic/server_settings_test.go
  55. 758 0
      internal/tuic/server_test.go
  56. 578 0
      internal/tuic/socks_bridge.go
  57. 416 0
      internal/tuic/socks_bridge_test.go
  58. 200 0
      internal/tuic/stream_udp_lifecycle_test.go
  59. 47 23
      internal/tuic/types.go
  60. 37 1
      internal/tuic/types_test.go
  61. 186 0
      internal/tuic/udp_associations.go
  62. 57 30
      internal/web/job/tuic_job.go
  63. 182 0
      internal/web/job/tuic_job_test.go
  64. 135 0
      internal/web/job/tuic_journal.go
  65. 67 0
      internal/web/job/tuic_journal_test.go
  66. 1 1
      internal/web/network/serve_test.go
  67. 25 1
      internal/web/runtime/local.go
  68. 38 0
      internal/web/runtime/local_tuic_test.go
  69. 87 1
      internal/web/service/client_link.go
  70. 32 0
      internal/web/service/client_link_postgres_test.go
  71. 56 7
      internal/web/service/inbound.go
  72. 11 2
      internal/web/service/inbound_amneziawg.go
  73. 106 0
      internal/web/service/inbound_client_traffic_test.go
  74. 59 0
      internal/web/service/inbound_clients_tuic_test.go
  75. 45 2
      internal/web/service/inbound_traffic.go
  76. 5 1
      internal/web/service/inbound_tuic.go
  77. 337 0
      internal/web/service/inbound_tuic_test.go
  78. 117 0
      internal/web/service/port_conflict.go
  79. 151 0
      internal/web/service/tuic_accounting_identity_test.go
  80. 108 0
      internal/web/service/tuic_settings.go
  81. 32 0
      internal/web/service/tuic_traffic_receipt.go
  82. 34 0
      internal/web/service/xray.go
  83. 6 3
      internal/web/translation/ar-EG.json
  84. 6 3
      internal/web/translation/en-US.json
  85. 6 3
      internal/web/translation/es-ES.json
  86. 6 3
      internal/web/translation/fa-IR.json
  87. 6 3
      internal/web/translation/id-ID.json
  88. 6 3
      internal/web/translation/ja-JP.json
  89. 6 3
      internal/web/translation/pt-BR.json
  90. 6 3
      internal/web/translation/ru-RU.json
  91. 6 3
      internal/web/translation/tr-TR.json
  92. 6 3
      internal/web/translation/uk-UA.json
  93. 6 3
      internal/web/translation/vi-VN.json
  94. 6 3
      internal/web/translation/zh-CN.json
  95. 6 3
      internal/web/translation/zh-TW.json
  96. 20 12
      internal/web/web.go
  97. 93 0
      internal/web/web_tuic_shutdown_test.go
  98. 14 11
      internal/xray/client_traffic.go
  99. 3 1
      main.go
  100. 2 3
      update.sh

+ 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

+ 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:
 

+ 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**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the sidecar, so the node must run panel v3.8.0 or newer; the master refuses an older node.
+  - **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 можно создать на дочернем узле или клонировать туда. Sidecar запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
+  - **Нативный 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);

+ 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,

+ 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 - 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"`
+}

+ 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()
+	}
+}

+ 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)
+	}
+}

+ 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)
+	}
+}

+ 56 - 7
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
@@ -1129,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
@@ -1310,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
@@ -1772,6 +1802,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	// 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
 	}
@@ -2136,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)
@@ -2149,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

+ 11 - 2
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"
 )
 
@@ -380,10 +381,18 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo
 			}
 			return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
 		}
-		if ctx.onNode || 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 == "" {

+ 106 - 0
internal/web/service/inbound_client_traffic_test.go

@@ -223,3 +223,109 @@ func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
 		t.Errorf("normal traffic not applied: up=%d down=%d, want 30/40", normal.Up, normal.Down)
 	}
 }
+
+func TestAddTrafficClientUpdateFailureRollsBackWholeBatch(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
+	db := database.GetDB()
+
+	for _, email := range []string{"healthy@x", "rejected@x"} {
+		if err := db.Create(&xray.ClientTraffic{Email: email, Enable: true}).Error; err != nil {
+			t.Fatalf("create client traffic %s: %v", email, err)
+		}
+	}
+	if err := db.Exec(`
+		CREATE TRIGGER reject_client_traffic_update
+		BEFORE UPDATE OF up, down ON client_traffics
+		WHEN OLD.email = 'rejected@x'
+		BEGIN
+			SELECT RAISE(ABORT, 'blocked client traffic update');
+		END`).Error; err != nil {
+		t.Fatalf("create update trigger: %v", err)
+	}
+
+	batch := []*xray.ClientTraffic{
+		{Email: "healthy@x", Up: 100, Down: 200},
+		{Email: "rejected@x", Up: 300, Down: 400},
+	}
+	svc := &InboundService{}
+	if _, _, err := svc.AddTraffic(nil, batch); err == nil {
+		t.Fatal("AddTraffic succeeded despite a client UPDATE failure")
+	}
+	assertTraffic := func(email string, up, down int64) {
+		t.Helper()
+		var got xray.ClientTraffic
+		if err := db.Where("email = ?", email).First(&got).Error; err != nil {
+			t.Fatalf("load traffic for %s: %v", email, err)
+		}
+		if got.Up != up || got.Down != down {
+			t.Fatalf("traffic for %s = (%d,%d), want (%d,%d)", email, got.Up, got.Down, up, down)
+		}
+	}
+	assertTraffic("healthy@x", 0, 0)
+	assertTraffic("rejected@x", 0, 0)
+
+	if err := db.Exec("DROP TRIGGER reject_client_traffic_update").Error; err != nil {
+		t.Fatalf("drop update trigger: %v", err)
+	}
+	if _, _, err := svc.AddTraffic(nil, batch); err != nil {
+		t.Fatalf("retry AddTraffic: %v", err)
+	}
+	assertTraffic("healthy@x", 100, 200)
+	assertTraffic("rejected@x", 300, 400)
+}
+
+func TestAddClientTrafficResolvesRenamedTuicClientByStableIdentity(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
+	db := database.GetDB()
+
+	const (
+		inboundID  = 18001
+		clientUUID = "a0000000-0000-0000-0000-000000000021"
+		oldEmail   = "before-rename@x"
+		newEmail   = "after-rename@x"
+	)
+	inbound := &model.Inbound{Id: inboundID, Tag: "tuic-rename", Enable: true, Port: 0, Protocol: model.TUIC}
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: newEmail, UUID: clientUUID, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("create renamed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inboundID}).Error; err != nil {
+		t.Fatalf("create client-inbound link: %v", err)
+	}
+	for _, email := range []string{oldEmail, newEmail} {
+		if err := db.Create(&xray.ClientTraffic{InboundId: inboundID, Email: email, Enable: true}).Error; err != nil {
+			t.Fatalf("create traffic row %s: %v", email, err)
+		}
+	}
+
+	var currentTraffic xray.ClientTraffic
+	if err := db.Where("email = ?", newEmail).First(&currentTraffic).Error; err != nil {
+		t.Fatal(err)
+	}
+	stableTrafficID := currentTraffic.Id
+
+	if err := (&InboundService{}).addClientTraffic(db, []*xray.ClientTraffic{{
+		Email: oldEmail, TuicTrafficID: stableTrafficID, TuicUUID: clientUUID, TuicInboundId: inboundID, Up: 123, Down: 456,
+	}}); err != nil {
+		t.Fatalf("add retired snapshot traffic: %v", err)
+	}
+	for _, test := range []struct {
+		email    string
+		up, down int64
+	}{{oldEmail, 0, 0}, {newEmail, 123, 456}} {
+		var got xray.ClientTraffic
+		if err := db.Where("email = ?", test.email).First(&got).Error; err != nil {
+			t.Fatalf("load traffic for %s: %v", test.email, err)
+		}
+		if got.Up != test.up || got.Down != test.down {
+			t.Errorf("traffic for %s = (%d,%d), want (%d,%d)", test.email, got.Up, got.Down, test.up, test.down)
+		}
+	}
+}

+ 59 - 0
internal/web/service/inbound_clients_tuic_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 
 func TestBuildTargetClientFromSourceTuic(t *testing.T) {
@@ -64,3 +65,61 @@ func TestAddInboundTuicClientValidation(t *testing.T) {
 		t.Fatalf("expected 'empty client email' error, got %v", errNoEmail)
 	}
 }
+
+func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
+	setupConflictDB(t)
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	inboundSvc := &InboundService{}
+	clientSvc := &ClientService{}
+
+	ib := &model.Inbound{
+		Tag:      "tuic-restart-test",
+		Protocol: model.TUIC,
+		Port:     54321,
+		Enable:   true,
+		Settings: `{
+			"certificate": "dummy-cert",
+			"private_key": "dummy-key",
+			"clients":[{"id":"a0000000-0000-0000-0000-000000000001","password":"p1","email":"[email protected]","enable":true}]
+		}`,
+	}
+	created, _, err := inboundSvc.AddInbound(ib)
+	if err != nil {
+		t.Fatalf("AddInbound failed: %v", err)
+	}
+
+	addPayload := &model.Inbound{
+		Id:       created.Id,
+		Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"p2","email":"[email protected]","enable":true}]}`,
+	}
+	needRestart, err := clientSvc.AddInboundClient(inboundSvc, addPayload)
+	if err != nil {
+		t.Fatalf("AddInboundClient failed: %v", err)
+	}
+	if needRestart {
+		t.Fatal("expected needRestart = false when adding TUIC client")
+	}
+
+	updatePayload := &model.Inbound{
+		Id:       created.Id,
+		Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"new-p2","email":"[email protected]","enable":true}]}`,
+	}
+	needRestart, err = clientSvc.UpdateInboundClient(inboundSvc, updatePayload, "[email protected]")
+	if err != nil {
+		t.Fatalf("UpdateInboundClient failed: %v", err)
+	}
+	if needRestart {
+		t.Fatal("expected needRestart = false when updating TUIC client")
+	}
+
+	needRestart, err = clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "[email protected]", false, true)
+	if err != nil {
+		t.Fatalf("DelInboundClientByEmail failed: %v", err)
+	}
+	if needRestart {
+		t.Fatal("expected needRestart = false when deleting TUIC client")
+	}
+}

+ 45 - 2
internal/web/service/inbound_traffic.go

@@ -133,6 +133,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 	if len(traffics) == 0 {
 		return nil
 	}
+	traffics, err = canonicalizeClientTraffic(tx, traffics)
+	if err != nil {
+		return fmt.Errorf("resolve client traffic identities: %w", err)
+	}
 
 	emails := make([]string, 0, len(traffics))
 	for _, traffic := range traffics {
@@ -150,6 +154,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 	// conflict, but this filter was removed rather than relying on that ordering).
 	err = tx.Model(xray.ClientTraffic{}).
 		Where("email IN (?)", emails).
+		Order("id").
 		Find(&dbClientTraffics).Error
 	if err != nil {
 		return err
@@ -192,7 +197,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 			),
 			t.Up, t.Down, now, ct.Email,
 		).Error; err != nil {
-			logger.Warning("AddClientTraffic update data ", err)
+			return fmt.Errorf("update traffic for %s: %w", ct.Email, err)
 		}
 	}
 
@@ -206,13 +211,51 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 			`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
 			convertedExpiryByEmail[email], email,
 		).Error; err != nil {
-			logger.Warning("AddClientTraffic update expiry_time ", err)
+			return fmt.Errorf("update expiry time for %s: %w", email, err)
 		}
 	}
 
 	return nil
 }
 
+func canonicalizeClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
+	byEmail := make(map[string]*xray.ClientTraffic, len(traffics))
+	for _, traffic := range traffics {
+		if traffic == nil {
+			continue
+		}
+		email := traffic.Email
+		if traffic.TuicTrafficID > 0 {
+			var owner xray.ClientTraffic
+			err := tx.Select("email").Where("id = ?", traffic.TuicTrafficID).Take(&owner).Error
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				continue
+			}
+			if err != nil {
+				return nil, err
+			}
+			email = owner.Email
+		}
+		current := byEmail[email]
+		if current == nil {
+			copy := *traffic
+			copy.Email = email
+			copy.TuicUUID = ""
+			copy.TuicInboundId = 0
+			byEmail[email] = &copy
+			continue
+		}
+		current.Up += traffic.Up
+		current.Down += traffic.Down
+		current.Enable = current.Enable || traffic.Enable
+	}
+	result := make([]*xray.ClientTraffic, 0, len(byEmail))
+	for _, traffic := range byEmail {
+		result = append(result, traffic)
+	}
+	return result, nil
+}
+
 func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, map[string]int64, error) {
 	now := time.Now().UnixMilli()
 

+ 5 - 1
internal/web/service/inbound_tuic.go

@@ -24,7 +24,11 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
 
 	instances := make([]tuic.Instance, 0, len(inbounds))
 	for _, ib := range inbounds {
-		inst, ok := tuic.InstanceFromInbound(ib)
+		built, err := s.buildInboundForLocalRuntime(db, ib)
+		if err != nil {
+			return nil, err
+		}
+		inst, ok := tuic.InstanceFromInbound(built)
 		if !ok {
 			continue
 		}

+ 337 - 0
internal/web/service/inbound_tuic_test.go

@@ -0,0 +1,337 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"testing"
+
+	"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 TestInjectTuicSocks(t *testing.T) {
+	cfg := &xray.Config{}
+	inbounds := []*model.Inbound{
+		{
+			Id:       5,
+			Tag:      "tuic-in-5",
+			Protocol: model.TUIC,
+			Enable:   true,
+			Settings: `{
+				"certificate": "dummy-cert",
+				"private_key": "dummy-key",
+				"clients": [
+					{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "pass1", "email": "[email protected]", "enable": true}
+				]
+			}`,
+		},
+	}
+
+	injectTuicSocks(cfg, inbounds)
+
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("expected 1 injected SOCKS inbound, got %d", len(cfg.InboundConfigs))
+	}
+
+	sc := cfg.InboundConfigs[0]
+	if sc.Tag != "tuic-in-5" {
+		t.Fatalf("expected tag tuic-in-5, got %s", sc.Tag)
+	}
+	if sc.Protocol != "socks" {
+		t.Fatalf("expected protocol socks, got %s", sc.Protocol)
+	}
+	expectedPort := tuic.SOCKSPortForInbound(5)
+	if sc.Port != expectedPort {
+		t.Fatalf("expected port %d, got %d", expectedPort, sc.Port)
+	}
+	if string(sc.Listen) != `"127.0.0.1"` {
+		t.Fatalf("expected listen 127.0.0.1, got %s", sc.Listen)
+	}
+	if string(sc.Sniffing) != tuicEgressSniffingSettings {
+		t.Fatalf("expected sniffing settings %s, got %s", tuicEgressSniffingSettings, sc.Sniffing)
+	}
+
+	var parsedSettings struct {
+		Auth string `json:"auth"`
+		UDP  bool   `json:"udp"`
+	}
+	if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
+		t.Fatalf("failed to unmarshal settings: %v", err)
+	}
+	if parsedSettings.Auth != "noauth" || !parsedSettings.UDP {
+		t.Fatalf("expected auth=noauth, udp=true, got %+v", parsedSettings)
+	}
+}
+
+func TestCheckTuicSocksConflict(t *testing.T) {
+	setupConflictDB(t)
+
+	// Seed TUIC inbound with ID 10
+	tuicIb := &model.Inbound{
+		Id:       10,
+		Tag:      "tuic-10",
+		Protocol: model.TUIC,
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     8443,
+		Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"[email protected]"}]}`,
+	}
+	if err := database.GetDB().Create(tuicIb).Error; err != nil {
+		t.Fatalf("failed to seed TUIC inbound: %v", err)
+	}
+
+	relayPort := tuic.SOCKSPortForInbound(10)
+
+	// Try to create a new TCP inbound on that relayPort on 127.0.0.1
+	newIb := &model.Inbound{
+		Tag:      "colliding-inbound",
+		Protocol: model.Mixed,
+		Enable:   true,
+		Listen:   "127.0.0.1",
+		Port:     relayPort,
+	}
+
+	detail, err := checkTuicSocksConflict(database.GetDB(), newIb, 0, transportTCP)
+	if err != nil {
+		t.Fatalf("checkTuicSocksConflict error: %v", err)
+	}
+	if detail == nil {
+		t.Fatalf("expected conflict on port %d, got none", relayPort)
+	}
+	if detail.Tag != "tuic-10" {
+		t.Fatalf("expected conflict tag tuic-10, got %s", detail.Tag)
+	}
+}
+
+func TestCheckTuicSocksReverseConflict(t *testing.T) {
+	setupConflictDB(t)
+
+	targetPort := tuic.SOCKSPortForInbound(20)
+
+	// Seed existing inbound on targetPort on 127.0.0.1
+	existing := &model.Inbound{
+		Id:       99,
+		Tag:      "existing-on-relay-port",
+		Protocol: model.Mixed,
+		Enable:   true,
+		Listen:   "127.0.0.1",
+		Port:     targetPort,
+	}
+	if err := database.GetDB().Create(existing).Error; err != nil {
+		t.Fatalf("failed to seed existing inbound: %v", err)
+	}
+
+	detail, err := checkTuicSocksReverseConflict(database.GetDB(), 20)
+	if err != nil {
+		t.Fatalf("checkTuicSocksReverseConflict error: %v", err)
+	}
+	if detail == nil {
+		t.Fatalf("expected reverse conflict for id 20 on port %d, got none", targetPort)
+	}
+	if detail.Tag != "existing-on-relay-port" {
+		t.Fatalf("expected tag existing-on-relay-port, got %s", detail.Tag)
+	}
+}
+
+func TestDesiredTuicInstances(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := &model.Inbound{
+		Id:       30,
+		Tag:      "tuic-desired-test",
+		Protocol: model.TUIC,
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     9443,
+		Settings: `{
+			"certificate": "cert",
+			"private_key": "key",
+			"clients": [
+				{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "p1", "email": "[email protected]", "enable": true},
+				{"uuid": "a0000000-0000-0000-0000-000000000002", "password": "p2", "email": "[email protected]", "enable": true}
+			]
+		}`,
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("failed to seed inbound: %v", err)
+	}
+
+	// Add client traffic entry disabling [email protected]
+	ct := &xray.ClientTraffic{
+		InboundId: 30,
+		Email:     "[email protected]",
+		Enable:    false,
+	}
+	if err := database.GetDB().Create(ct).Error; err != nil {
+		t.Fatalf("failed to seed client traffic: %v", err)
+	}
+
+	svc := &InboundService{}
+	instances, err := svc.DesiredTuicInstances()
+	if err != nil {
+		t.Fatalf("DesiredTuicInstances failed: %v", err)
+	}
+
+	found := false
+	for _, inst := range instances {
+		if inst.Id == 30 {
+			found = true
+			if len(inst.Clients) != 1 || inst.Clients[0].Email != "[email protected]" {
+				t.Fatalf("expected only [email protected], got %+v", inst.Clients)
+			}
+		}
+	}
+	if !found {
+		t.Fatal("expected to find instance for inbound 30")
+	}
+}
+
+func TestCheckForwardedPortsConflict_CollidesWithTuicSocksPort(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "tuic-1", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
+
+	var tuicInbound model.Inbound
+	if err := database.GetDB().Where("tag = ?", "tuic-1").First(&tuicInbound).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+	relayPort := tuic.SOCKSPortForInbound(tuicInbound.Id)
+
+	svc := &InboundService{}
+	ctx, err := svc.loadPortConflictContext(database.GetDB(), nil)
+	if err != nil {
+		t.Fatalf("loadPortConflictContext: %v", err)
+	}
+	hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
+	if !strings.Contains(hit, "SOCKS5") {
+		t.Fatalf("expected a collision naming the TUIC inbound's SOCKS5 relay port, got %q", hit)
+	}
+}
+
+func TestCheckTuicSocksConflict_DisabledInboundRetainsReservation(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "tuic-disabled", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
+
+	var tuicIb model.Inbound
+	if err := database.GetDB().Where("tag = ?", "tuic-disabled").First(&tuicIb).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+	if err := database.GetDB().Model(&tuicIb).Update("enable", false).Error; err != nil {
+		t.Fatalf("disable inbound: %v", err)
+	}
+
+	relayPort := tuic.SOCKSPortForInbound(tuicIb.Id)
+
+	testIb := &model.Inbound{
+		Tag:      "conflict-test",
+		Protocol: model.VLESS,
+		Listen:   "127.0.0.1",
+		Port:     relayPort,
+		Enable:   true,
+	}
+
+	conflict, err := checkTuicSocksConflict(database.GetDB(), testIb, 0, transportTCP)
+	if err != nil {
+		t.Fatalf("checkTuicSocksConflict: %v", err)
+	}
+	if conflict == nil {
+		t.Fatal("expected conflict on disabled TUIC inbound's SOCKS port, got nil")
+	}
+	if conflict.InboundID != tuicIb.Id {
+		t.Fatalf("expected conflict with inbound %d, got %d", tuicIb.Id, conflict.InboundID)
+	}
+}
+
+func TestCheckTuicSocksRelayCollision(t *testing.T) {
+	setupConflictDB(t)
+	// Seed first TUIC inbound with ID 1
+	ib1 := &model.Inbound{
+		Id:       1,
+		Tag:      "tuic-1",
+		Protocol: model.TUIC,
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     8443,
+		Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"[email protected]"}]}`,
+	}
+	if err := database.GetDB().Create(ib1).Error; err != nil {
+		t.Fatalf("seed ib1: %v", err)
+	}
+
+	// ID 1001 wraps to the same relay port (64001) as ID 1
+	conflict, err := checkTuicSocksRelayCollision(database.GetDB(), 1001)
+	if err != nil {
+		t.Fatalf("checkTuicSocksRelayCollision: %v", err)
+	}
+	if conflict == nil {
+		t.Fatal("expected collision between ID 1001 and ID 1, got nil")
+	}
+	if conflict.InboundID != 1 {
+		t.Fatalf("expected collision with inbound 1, got %d", conflict.InboundID)
+	}
+}
+
+func TestTuicSocksSelfConflict(t *testing.T) {
+	ib := &model.Inbound{
+		Protocol: model.TUIC,
+		Listen:   "127.0.0.1",
+		Port:     tuic.SOCKSPortForInbound(5),
+	}
+	errStr := tuicSocksSelfConflict(ib, 5)
+	if errStr == "" {
+		t.Fatal("expected self conflict error string, got empty")
+	}
+	if !strings.Contains(errStr, "own SOCKS5 relay port") {
+		t.Fatalf("unexpected error string: %s", errStr)
+	}
+
+	// Different port should not conflict
+	ib.Port = 9999
+	if diff := tuicSocksSelfConflict(ib, 5); diff != "" {
+		t.Fatalf("expected no conflict for different port, got %s", diff)
+	}
+}
+
+func TestInboundTuicServerParsesLegacyFlatSettings(t *testing.T) {
+	server := inboundTuicServer(string(model.TUIC), `{"certificate":"/cert.pem","private_key":"/secret-key.pem","congestion_control":" CuBiC ","udp_relay_mode":"quic","sni":"profile.example"}`)
+	if server == nil {
+		t.Fatal("expected legacy flat TUIC settings")
+	}
+	if server.CongestionControl != "cubic" || server.UDPRelayMode != "quic" || server.SNI != "profile.example" {
+		t.Fatalf("legacy flat fields were not normalized: %+v", server)
+	}
+	if server.PrivateKey != "" {
+		t.Fatal("client preview exposed the inbound private key")
+	}
+}
+
+func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T) {
+	ib := &model.Inbound{Protocol: model.TUIC, Settings: `{"congestion_control":"RENO","max_udp_relay_packet_size":65507,"server":{"congestion_control":" CuBiC ","max_udp_relay_packet_size":65500}}`}
+	if err := normalizeTuicSettings(ib); err != nil {
+		t.Fatalf("normalizeTuicSettings: %v", err)
+	}
+	var got struct {
+		CongestionControl string `json:"congestion_control"`
+		MaxPacketSize     int    `json:"max_udp_relay_packet_size"`
+		Server            struct {
+			CongestionControl string `json:"congestion_control"`
+			MaxPacketSize     int    `json:"max_udp_relay_packet_size"`
+		} `json:"server"`
+	}
+	if err := json.Unmarshal([]byte(ib.Settings), &got); err != nil {
+		t.Fatalf("unmarshal normalized settings: %v", err)
+	}
+	if got.CongestionControl != "new_reno" || got.Server.CongestionControl != "cubic" {
+		t.Fatalf("congestion controllers were not canonicalized: %+v", got)
+	}
+	if got.MaxPacketSize != 65245 || got.Server.MaxPacketSize != 65245 {
+		t.Fatalf("packet limits were not clamped: %+v", got)
+	}
+
+	ib.Settings = `{"server":{"congestion_control":"experimental"}}`
+	if err := normalizeTuicSettings(ib); err == nil {
+		t.Fatal("unsupported congestion controller was accepted")
+	}
+}

+ 117 - 0
internal/web/service/port_conflict.go

@@ -10,6 +10,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"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/util/common"
 
 	"gorm.io/gorm"
@@ -284,6 +285,13 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		if conflict != nil {
 			return conflict, nil
 		}
+		conflict, err = checkTuicSocksConflict(db, inbound, ignoreId, newBits)
+		if err != nil {
+			return nil, err
+		}
+		if conflict != nil {
+			return conflict, nil
+		}
 	}
 
 	// The reverse direction, only meaningful once the id is known -- AddInbound
@@ -318,6 +326,25 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		forwardedBy.Transports = newBits
 		return forwardedBy, nil
 	}
+	if inbound.NodeID == nil && inbound.Protocol == model.TUIC && ignoreId > 0 {
+		if self := tuicSocksSelfConflict(inbound, ignoreId); self != "" {
+			return nil, common.NewError(self)
+		}
+		conflict, err := checkTuicSocksRelayCollision(db, ignoreId)
+		if err != nil {
+			return nil, err
+		}
+		if conflict != nil {
+			return conflict, nil
+		}
+		conflict, err = checkTuicSocksReverseConflict(db, ignoreId)
+		if err != nil {
+			return nil, err
+		}
+		if conflict != nil {
+			return conflict, nil
+		}
+	}
 
 	var candidates []*model.Inbound
 	q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
@@ -488,6 +515,96 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
 	return nil, nil
 }
 
+func checkTuicSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
+	var candidates []*model.Inbound
+	q := db.Model(model.Inbound{}).Where("protocol = ? AND node_id IS NULL", model.TUIC)
+	if ignoreId > 0 {
+		q = q.Where("id != ?", ignoreId)
+	}
+	if err := q.Find(&candidates).Error; err != nil {
+		return nil, err
+	}
+	for _, c := range candidates {
+		if _, ok := tuic.InstanceFromInbound(c); !ok {
+			continue
+		}
+		if tuic.SOCKSPortForInbound(c.Id) != inbound.Port {
+			continue
+		}
+		return &portConflictDetail{
+			InboundID:  c.Id,
+			Remark:     c.Remark,
+			Tag:        c.Tag,
+			Listen:     "127.0.0.1",
+			Port:       inbound.Port,
+			Transports: newBits,
+		}, nil
+	}
+	return nil, nil
+}
+
+func checkTuicSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
+	relayPort := tuic.SOCKSPortForInbound(id)
+	var candidates []*model.Inbound
+	if err := db.Model(model.Inbound{}).
+		Where("protocol = ? AND node_id IS NULL AND id != ?", model.TUIC, id).
+		Find(&candidates).Error; err != nil {
+		return nil, err
+	}
+	for _, c := range candidates {
+		if tuic.SOCKSPortForInbound(c.Id) != relayPort {
+			continue
+		}
+		return &portConflictDetail{
+			InboundID:  c.Id,
+			Remark:     c.Remark,
+			Tag:        c.Tag,
+			Listen:     "127.0.0.1",
+			Port:       relayPort,
+			Relay:      true,
+			Transports: transportTCP,
+		}, nil
+	}
+	return nil, nil
+}
+
+func tuicSocksSelfConflict(inbound *model.Inbound, id int) string {
+	if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
+		return ""
+	}
+	relayPort := tuic.SOCKSPortForInbound(id)
+	if inbound.Port != relayPort {
+		return ""
+	}
+	return fmt.Sprintf("TUIC port %d is inbound #%d's own SOCKS5 relay port on 127.0.0.1; choose a different TUIC port",
+		relayPort, id)
+}
+
+func checkTuicSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
+	relayPort := tuic.SOCKSPortForInbound(id)
+	var candidates []*model.Inbound
+	if err := db.Model(model.Inbound{}).
+		Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
+		Find(&candidates).Error; err != nil {
+		return nil, err
+	}
+	for _, c := range candidates {
+		if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
+			continue
+		}
+		return &portConflictDetail{
+			InboundID:  c.Id,
+			Remark:     c.Remark,
+			Tag:        c.Tag,
+			Listen:     c.Listen,
+			Port:       relayPort,
+			Relay:      true,
+			Transports: transportTCP,
+		}, nil
+	}
+	return nil, nil
+}
+
 func sameNode(a, b *int) bool {
 	if a == nil && b == nil {
 		return true

+ 151 - 0
internal/web/service/tuic_accounting_identity_test.go

@@ -0,0 +1,151 @@
+package service
+
+import (
+	"testing"
+
+	"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 audit3CreateTuicClient(t *testing.T, svc *ClientService, inboundSvc *InboundService, ib *model.Inbound, email, id string) model.ClientRecord {
+	t.Helper()
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: model.Client{Email: email, ID: id, Password: "pw", Enable: true}, InboundIds: []int{ib.Id}}); err != nil {
+		t.Fatalf("public Create %s: %v", email, err)
+	}
+	return lookupClientRecord(t, email)
+}
+
+func audit3Traffic(t *testing.T, email string) xray.ClientTraffic {
+	t.Helper()
+	var out xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", email).First(&out).Error; err != nil {
+		t.Fatal(err)
+	}
+	return out
+}
+
+func TestAudit3DeletedIdentityMustNotChargeDuplicateUUID(t *testing.T) {
+	setupBulkDB(t)
+	svc, inboundSvc := &ClientService{}, &InboundService{}
+	ibB := mkInbound(t, 28001, model.TUIC, `{"clients":[]}`)
+	ibA := mkInbound(t, 28002, model.TUIC, `{"clients":[]}`)
+	const id = "a0000000-0000-0000-0000-000000000031"
+	audit3CreateTuicClient(t, svc, inboundSvc, ibB, "b@audit3", id)
+	a := audit3CreateTuicClient(t, svc, inboundSvc, ibA, "a@audit3", id)
+	trafficID := audit3RuntimeTrafficID(t, inboundSvc, ibA, "a@audit3")
+	if _, err := svc.Delete(inboundSvc, a.Id, true); err != nil {
+		t.Fatalf("public Delete keepTraffic: %v", err)
+	}
+	if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "a@audit3", TuicTrafficID: trafficID, TuicUUID: id, TuicInboundId: ibA.Id, Up: 123, Down: 456}}); err != nil {
+		t.Fatal(err)
+	}
+	old, other := audit3Traffic(t, "a@audit3"), audit3Traffic(t, "b@audit3")
+	if old.Up != 123 || old.Down != 456 || other.Up != 0 || other.Down != 0 {
+		t.Fatalf("retired A=%d/%d; unrelated B=%d/%d, want A=123/456 and B=0/0", old.Up, old.Down, other.Up, other.Down)
+	}
+}
+
+func TestAudit3RenameAndRotateCredentialsMustPreserveRetiredUsage(t *testing.T) {
+	setupBulkDB(t)
+	svc, inboundSvc := &ClientService{}, &InboundService{}
+	ib := mkInbound(t, 28003, model.TUIC, `{"clients":[]}`)
+	const oldID = "a0000000-0000-0000-0000-000000000032"
+	a := audit3CreateTuicClient(t, svc, inboundSvc, ib, "old@audit3", oldID)
+	trafficID := audit3RuntimeTrafficID(t, inboundSvc, ib, "old@audit3")
+	if _, err := svc.Update(inboundSvc, a.Id, model.Client{Email: "new@audit3", ID: "a0000000-0000-0000-0000-000000000033", Password: "newpw", Enable: true}, 0); err != nil {
+		t.Fatalf("public rename + rotate: %v", err)
+	}
+	if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "old@audit3", TuicTrafficID: trafficID, TuicUUID: oldID, TuicInboundId: ib.Id, Up: 123, Down: 456}}); err != nil {
+		t.Fatal(err)
+	}
+	got := audit3Traffic(t, "new@audit3")
+	if got.Up != 123 || got.Down != 456 {
+		t.Fatalf("new account usage=%d/%d, want retired 123/456", got.Up, got.Down)
+	}
+}
+
+func TestAudit3PublicRenamePreservesRetiredUsage(t *testing.T) {
+	setupBulkDB(t)
+	svc, inboundSvc := &ClientService{}, &InboundService{}
+	ib := mkInbound(t, 28004, model.TUIC, `{"clients":[]}`)
+	const id = "a0000000-0000-0000-0000-000000000034"
+	a := audit3CreateTuicClient(t, svc, inboundSvc, ib, "old@audit3", id)
+	trafficID := audit3RuntimeTrafficID(t, inboundSvc, ib, "old@audit3")
+	if _, err := svc.Update(inboundSvc, a.Id, model.Client{Email: "new@audit3", ID: id, Password: "pw", Enable: true}, 0); err != nil {
+		t.Fatalf("public rename: %v", err)
+	}
+	if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "old@audit3", TuicTrafficID: trafficID, TuicUUID: id, TuicInboundId: ib.Id, Up: 123, Down: 456}}); err != nil {
+		t.Fatal(err)
+	}
+	got := audit3Traffic(t, "new@audit3")
+	if got.Up != 123 || got.Down != 456 {
+		t.Fatalf("new account usage=%d/%d, want retired 123/456", got.Up, got.Down)
+	}
+}
+
+func TestAudit3SameInboundDuplicateUUIDMustBeRejectedOrBillMatchedPrincipal(t *testing.T) {
+	setupBulkDB(t)
+	svc, inboundSvc := &ClientService{}, &InboundService{}
+	ib := mkInbound(t, 28005, model.TUIC, `{"clients":[]}`)
+	const id = "a0000000-0000-0000-0000-000000000035"
+	audit3CreateTuicClient(t, svc, inboundSvc, ib, "a@audit3", id)
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: model.Client{Email: "b@audit3", ID: id, Password: "different-pw", Enable: true}, InboundIds: []int{ib.Id}}); err != nil {
+		t.Logf("duplicate correctly rejected: %v", err)
+		return
+	}
+	t.Fatal("public Create accepted duplicate UUID")
+}
+
+func audit3RuntimeTrafficID(t *testing.T, service *InboundService, inbound *model.Inbound, email string) int {
+	t.Helper()
+	fresh, err := service.GetInbound(inbound.Id)
+	if err != nil {
+		t.Fatal(err)
+	}
+	built, err := service.buildInboundForLocalRuntime(database.GetDB(), fresh)
+	if err != nil {
+		t.Fatal(err)
+	}
+	instance, ok := tuic.InstanceFromInbound(built)
+	if !ok {
+		t.Fatal("invalid runtime settings")
+	}
+	for _, client := range instance.Clients {
+		if client.Email == email {
+			if client.TrafficID == 0 || client.TrafficID != audit3Traffic(t, email).Id {
+				t.Fatal("runtime snapshot lacks immutable accounting identity")
+			}
+			return client.TrafficID
+		}
+	}
+	t.Fatal("runtime client missing")
+	return 0
+}
+
+func TestTuicDeletedTrafficRowCannotBillReusedEmail(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	old := xray.ClientTraffic{Email: "reused@audit3", Enable: true}
+	if err := db.Create(&old).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Delete(&old).Error; err != nil {
+		t.Fatal(err)
+	}
+	replacement := xray.ClientTraffic{Email: old.Email, Enable: true}
+	if err := db.Create(&replacement).Error; err != nil {
+		t.Fatal(err)
+	}
+	if old.Id == replacement.Id {
+		t.Fatal("accounting primary key reused")
+	}
+	if _, _, err := (&InboundService{}).AddTraffic(nil, []*xray.ClientTraffic{{Email: old.Email, TuicTrafficID: old.Id, Up: 123, Down: 456}}); err != nil {
+		t.Fatal(err)
+	}
+	got := audit3Traffic(t, old.Email)
+	if got.Up != 0 || got.Down != 0 {
+		t.Fatalf("retired client billed replacement: %+v", got)
+	}
+}

+ 108 - 0
internal/web/service/tuic_settings.go

@@ -0,0 +1,108 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+)
+
+func normalizeTuicSettings(inbound *model.Inbound) error {
+	if inbound == nil || inbound.Protocol != model.TUIC || inbound.Settings == "" {
+		return nil
+	}
+	var root map[string]json.RawMessage
+	if err := json.Unmarshal([]byte(inbound.Settings), &root); err != nil {
+		return fmt.Errorf("invalid TUIC settings: %w", err)
+	}
+	if err := normalizeTuicSettingsBlock(root); err != nil {
+		return err
+	}
+	if rawServer, ok := root["server"]; ok && len(rawServer) > 0 {
+		var server map[string]json.RawMessage
+		if err := json.Unmarshal(rawServer, &server); err != nil {
+			return fmt.Errorf("invalid TUIC server settings: %w", err)
+		}
+		if err := normalizeTuicSettingsBlock(server); err != nil {
+			return err
+		}
+		encoded, err := json.Marshal(server)
+		if err != nil {
+			return fmt.Errorf("encode TUIC server settings: %w", err)
+		}
+		root["server"] = encoded
+	}
+	encoded, err := json.Marshal(root)
+	if err != nil {
+		return fmt.Errorf("encode TUIC settings: %w", err)
+	}
+	inbound.Settings = string(encoded)
+	return nil
+}
+
+func normalizeTuicSettingsBlock(settings map[string]json.RawMessage) error {
+	if raw, ok := settings["congestion_control"]; ok {
+		var value string
+		if err := json.Unmarshal(raw, &value); err != nil {
+			return fmt.Errorf("TUIC congestion_control must be a string: %w", err)
+		}
+		normalized, err := tuic.NormalizeCongestionControl(value)
+		if err != nil {
+			return err
+		}
+		encoded, _ := json.Marshal(normalized)
+		settings["congestion_control"] = encoded
+	}
+	if raw, ok := settings["log_level"]; ok {
+		var value string
+		if err := json.Unmarshal(raw, &value); err != nil {
+			return fmt.Errorf("TUIC log_level must be a string: %w", err)
+		}
+		value = strings.ToLower(strings.TrimSpace(value))
+		if value == "warning" {
+			value = "warn"
+		}
+		if value == "" {
+			value = "info"
+		}
+		switch value {
+		case "debug", "info", "warn", "error":
+		default:
+			return fmt.Errorf("TUIC log_level %q is unsupported", value)
+		}
+		encoded, _ := json.Marshal(value)
+		settings["log_level"] = encoded
+	}
+	if raw, ok := settings["udp_relay_mode"]; ok {
+		var value string
+		if err := json.Unmarshal(raw, &value); err != nil {
+			return fmt.Errorf("TUIC udp_relay_mode must be a string: %w", err)
+		}
+		value = strings.ToLower(strings.TrimSpace(value))
+		if value == "" {
+			value = "native"
+		}
+		if value != "native" && value != "quic" {
+			return fmt.Errorf("TUIC udp_relay_mode %q is unsupported", value)
+		}
+		encoded, _ := json.Marshal(value)
+		settings["udp_relay_mode"] = encoded
+	}
+	if raw, ok := settings["max_udp_relay_packet_size"]; ok {
+		var value int
+		if err := json.Unmarshal(raw, &value); err != nil {
+			return fmt.Errorf("TUIC max_udp_relay_packet_size must be an integer: %w", err)
+		}
+		if value > 65507 {
+			return fmt.Errorf("TUIC max_udp_relay_packet_size must not exceed %d", 65245)
+		}
+		if value > 65245 {
+			value = 65245
+		}
+		encoded, _ := json.Marshal(value)
+		settings["max_udp_relay_packet_size"] = encoded
+	}
+	return nil
+}

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

@@ -0,0 +1,32 @@
+package service
+
+import (
+	"errors"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// AddTuicTrafficBatch commits a journal batch and its receipt atomically. A
+// retained journal can therefore be replayed after an ambiguous commit result.
+func (s *InboundService) AddTuicTrafficBatch(id string, traffic []*xray.ClientTraffic) error {
+	if id == "" {
+		return errors.New("TUIC traffic batch has no ID")
+	}
+	return submitTrafficWrite(func() error {
+		return database.GetDB().Transaction(func(tx *gorm.DB) error {
+			result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id})
+			if result.Error != nil {
+				return result.Error
+			}
+			if result.RowsAffected == 0 {
+				return nil
+			}
+			return s.addClientTraffic(tx, traffic)
+		})
+	})
+}

+ 34 - 0
internal/web/service/xray.go

@@ -16,6 +16,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -426,6 +427,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 	// whatever rules the admin adds through the stock Routing page, exactly
 	// like routing any other protocol.
 	injectAmneziawgnetSocks(xrayConfig, inbounds)
+	injectTuicSocks(xrayConfig, inbounds)
 
 	// Restores each opted-in peer's own distinct public IPv6 source identity
 	// for its outbound connections — a peer that has an IPv6 address in its
@@ -793,6 +795,38 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
 	}
 }
 
+const (
+	tuicEgressSocksSettings    = `{"auth":"noauth","udp":true}`
+	tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
+)
+
+func injectTuicSocks(cfg *xray.Config, inbounds []*model.Inbound) {
+	existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
+	for i := range cfg.InboundConfigs {
+		existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
+	}
+
+	for _, inbound := range inbounds {
+		if inbound.Protocol != model.TUIC || !inbound.Enable || inbound.NodeID != nil {
+			continue
+		}
+		if _, taken := existingTags[inbound.Tag]; taken {
+			logger.Warning("tuic socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
+			continue
+		}
+
+		existingTags[inbound.Tag] = struct{}{}
+		cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+			Listen:   json_util.RawMessage(`"127.0.0.1"`),
+			Port:     tuic.SOCKSPortForInbound(inbound.Id),
+			Protocol: "socks",
+			Settings: json_util.RawMessage(tuicEgressSocksSettings),
+			Sniffing: json_util.RawMessage(tuicEgressSniffingSettings),
+			Tag:      inbound.Tag,
+		})
+	}
+}
+
 // amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
 // tag for one peer's IPv6 source-identity egress. Stable across config
 // regenerations (a pure function of two stable identifiers), so

+ 6 - 3
internal/web/translation/ar-EG.json

@@ -966,7 +966,6 @@
       "renewOnDay": "يوم التجديد",
       "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
       "renewsUsed": "التجديدات المستخدمة",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "التحكم في الازدحام",
+        "congestionControlHint": "خوارزمية الخادم والعميل. تسري تغييرات الخادم بعد إعادة الاتصال.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "وضع ترحيل UDP",
+        "udpRelayModeHint": "وضع UDP للعميل. يدعم الخادم كلا الوضعين.",
         "zeroRttHandshake": "مصافحة 0-RTT",
         "logLevel": "مستوى السجل",
-        "logLevelHint": "يتم استنتاج حالة الاتصال وآخر ظهور و«البدء بعد أول استخدام» من سطور Info الخاصة بالعملية الجانبية؛ اختيار Warn أو Error يعطّلها لهذا الإنباوند.",
+        "logLevelHint": "أدنى مستوى للسجل لهذا الاتصال الوارد.",
         "maxIdleTime": "أقصى وقت خمول (ثوانٍ)",
         "authTimeout": "مهلة المصادقة (ثوانٍ)",
         "maxUdpRelayPacketSize": "أقصى حجم لحزمة UDP",
         "sni": "SNI",
-        "sniHint": "تجاوز إشارة اسم الخادم"
+        "sniHint": "اسم خادم TLS في ملف إعداد العميل.",
+        "sniRequired": "أدخل نطاقًا لملء مسارات الشهادة تلقائيًا."
       },
       "tun": {
         "userLevel": "مستوى المستخدم"

+ 6 - 3
internal/web/translation/en-US.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Renew on day",
       "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
       "renewsUsed": "Renewals used",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds."
     },
     "groups": {
@@ -2256,16 +2255,20 @@
       },
       "tuic": {
         "congestionControl": "Congestion Control",
+        "congestionControlHint": "Server and client algorithm. Server changes apply after reconnecting.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "UDP Relay Mode",
+        "udpRelayModeHint": "Client UDP mode. The server supports both.",
         "zeroRttHandshake": "Zero-RTT Handshake",
         "logLevel": "Log Level",
-        "logLevelHint": "Online status, last online and \"start after first use\" are read from the sidecar's Info lines; Warn or Error turns them off for this inbound.",
+        "logLevelHint": "Minimum log level for this inbound.",
         "maxIdleTime": "Max Idle Time (s)",
         "authTimeout": "Auth Timeout (s)",
         "maxUdpRelayPacketSize": "Max UDP Packet Size",
         "sni": "SNI",
-        "sniHint": "Server Name Indication override"
+        "sniHint": "TLS server name in the client profile.",
+        "sniRequired": "Enter a domain to auto-fill certificate paths."
       },
       "tun": {
         "userLevel": "User Level"

+ 6 - 3
internal/web/translation/es-ES.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Renovar el día",
       "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
       "renewsUsed": "Renovaciones usadas",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Control de congestión",
+        "congestionControlHint": "Algoritmo del servidor y cliente. Los cambios del servidor se aplican al reconectar.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "Modo de retransmisión UDP",
+        "udpRelayModeHint": "Modo UDP del cliente. El servidor admite ambos.",
         "zeroRttHandshake": "Apretón de manos 0-RTT",
         "logLevel": "Nivel de registro",
-        "logLevelHint": "El estado en línea, la última conexión y «iniciar tras el primer uso» se leen de las líneas Info del sidecar; Warn o Error los desactivan para este inbound.",
+        "logLevelHint": "Nivel mínimo de registro de esta entrada.",
         "maxIdleTime": "Tiempo máx. de inactividad (s)",
         "authTimeout": "Tiempo de espera de autenticación (s)",
         "maxUdpRelayPacketSize": "Tamaño máx. de paquete UDP",
         "sni": "SNI",
-        "sniHint": "Sobrescritura de Server Name Indication"
+        "sniHint": "Nombre del servidor TLS en el perfil del cliente.",
+        "sniRequired": "Introduce un dominio para completar las rutas del certificado."
       },
       "tun": {
         "userLevel": "Nivel de Usuario"

+ 6 - 3
internal/web/translation/fa-IR.json

@@ -966,7 +966,6 @@
       "renewOnDay": "روز تمدید",
       "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
       "renewsUsed": "تمدیدهای استفاده‌شده",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "کنترل ازدحام",
+        "congestionControlHint": "الگوریتم سرور و کلاینت. تغییر سرور پس از اتصال مجدد اعمال می‌شود.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "حالت رله UDP",
+        "udpRelayModeHint": "حالت UDP کلاینت. سرور از هر دو پشتیبانی می‌کند.",
         "zeroRttHandshake": "دست دادن 0-RTT",
         "logLevel": "سطح گزارش",
-        "logLevelHint": "وضعیت آنلاین، آخرین اتصال و «شروع پس از اولین استفاده» از خطوط Info سایدکار خوانده می‌شوند؛ Warn یا Error آن‌ها را برای این اینباند غیرفعال می‌کند.",
+        "logLevelHint": "حداقل سطح لاگ این ورودی.",
         "maxIdleTime": "حداکثر زمان بیکاری (ثانیه)",
         "authTimeout": "مهلت زمانی احراز هویت (ثانیه)",
         "maxUdpRelayPacketSize": "حداکثر اندازه بسته UDP",
         "sni": "SNI",
-        "sniHint": "بازنویسی نشانگر نام سرور"
+        "sniHint": "نام سرور TLS در پروفایل کلاینت.",
+        "sniRequired": "برای تکمیل خودکار مسیرهای گواهی، دامنه را وارد کنید."
       },
       "tun": {
         "userLevel": "سطح کاربر"

+ 6 - 3
internal/web/translation/id-ID.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Perpanjang pada tanggal",
       "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
       "renewsUsed": "Perpanjangan terpakai",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Kontrol Kemacetan",
+        "congestionControlHint": "Algoritma server dan klien. Perubahan server berlaku setelah terhubung ulang.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "Mode Relai UDP",
+        "udpRelayModeHint": "Mode UDP klien. Server mendukung keduanya.",
         "zeroRttHandshake": "Jabat Tangan 0-RTT",
         "logLevel": "Tingkat Log",
-        "logLevelHint": "Status online, terakhir online, dan \"mulai setelah penggunaan pertama\" dibaca dari baris Info sidecar; Warn atau Error menonaktifkannya untuk inbound ini.",
+        "logLevelHint": "Level log minimum untuk inbound ini.",
         "maxIdleTime": "Waktu Diam Maks (dtk)",
         "authTimeout": "Batas Waktu Otentikasi (dtk)",
         "maxUdpRelayPacketSize": "Ukuran Paket UDP Maks",
         "sni": "SNI",
-        "sniHint": "Penggantian Server Name Indication"
+        "sniHint": "Nama server TLS dalam profil klien.",
+        "sniRequired": "Masukkan domain untuk mengisi jalur sertifikat otomatis."
       },
       "tun": {
         "userLevel": "Level Pengguna"

+ 6 - 3
internal/web/translation/ja-JP.json

@@ -966,7 +966,6 @@
       "renewOnDay": "更新する日",
       "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
       "renewsUsed": "使用済み更新回数",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。"
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "輻輳制御",
+        "congestionControlHint": "サーバーとクライアントの方式。サーバーへの変更は再接続後に適用。",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "UDP リレーモード",
+        "udpRelayModeHint": "クライアントの UDP モード。サーバーは両方に対応。",
         "zeroRttHandshake": "0-RTT ハンドシェイク",
         "logLevel": "ログレベル",
-        "logLevelHint": "オンライン状態、最終オンライン、「初回使用後に開始」はサイドカーの Info 行から検出されます。Warn または Error を選ぶとこのインバウンドでは無効になります。",
+        "logLevelHint": "このインバウンドの最低ログレベル。",
         "maxIdleTime": "最大アイドル時間 (秒)",
         "authTimeout": "認証タイムアウト (秒)",
         "maxUdpRelayPacketSize": "最大 UDP パケットサイズ",
         "sni": "SNI",
-        "sniHint": "Server Name Indication の上書き"
+        "sniHint": "クライアント設定の TLS サーバー名。",
+        "sniRequired": "証明書パスの自動入力にはドメインを指定してください。"
       },
       "tun": {
         "userLevel": "ユーザーレベル"

+ 6 - 3
internal/web/translation/pt-BR.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Renovar no dia",
       "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
       "renewsUsed": "Renovações usadas",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Controle de congestionamento",
+        "congestionControlHint": "Algoritmo do servidor e cliente. Alterações no servidor valem após reconectar.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "Modo de retransmissão UDP",
+        "udpRelayModeHint": "Modo UDP do cliente. O servidor aceita ambos.",
         "zeroRttHandshake": "Handshake 0-RTT",
         "logLevel": "Nível de registro",
-        "logLevelHint": "Status online, último acesso e \"iniciar após o primeiro uso\" são lidos das linhas Info do sidecar; Warn ou Error os desativam para este inbound.",
+        "logLevelHint": "Nível mínimo de log desta entrada.",
         "maxIdleTime": "Tempo máx. ocioso (s)",
         "authTimeout": "Tempo limite de autenticação (s)",
         "maxUdpRelayPacketSize": "Tamanho máx. do pacote UDP",
         "sni": "SNI",
-        "sniHint": "Substituição do Server Name Indication"
+        "sniHint": "Nome do servidor TLS no perfil do cliente.",
+        "sniRequired": "Informe um domínio para preencher os caminhos do certificado."
       },
       "tun": {
         "userLevel": "Nível do Usuário"

+ 6 - 3
internal/web/translation/ru-RU.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Продлевать числа",
       "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
       "renewsUsed": "Продлений израсходовано",
-      "tuicTotalGBDesc": "TUIC не поддерживает персональные квоты трафика; задайте лимит на уровне инбаунда.",
       "bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Контроль перегрузок",
+        "congestionControlHint": "Алгоритм сервера и клиента. На сервере меняется после переподключения.",
+        "profileOptions": "Параметры клиентского профиля",
         "alpn": "ALPN",
         "udpRelayMode": "Режим ретрансляции UDP",
+        "udpRelayModeHint": "Режим UDP для клиента. Сервер поддерживает оба.",
         "zeroRttHandshake": "0-RTT рукопожатие",
         "logLevel": "Уровень логирования",
-        "logLevelHint": "Статус онлайн, «последний раз онлайн» и «старт после первого использования» читаются из строк Info сайдкара; Warn или Error отключают их для этого инбаунда.",
+        "logLevelHint": "Минимальный уровень сообщений в логах этого инбаунда.",
         "maxIdleTime": "Макс. время простоя (сек)",
         "authTimeout": "Таймаут аутентификации (сек)",
         "maxUdpRelayPacketSize": "Макс. размер пакета UDP",
         "sni": "SNI",
-        "sniHint": "Переопределение Server Name Indication"
+        "sniHint": "Имя сервера для TLS в клиентском профиле.",
+        "sniRequired": "Укажите домен для автозаполнения."
       },
       "tun": {
         "userLevel": "Уровень пользователя"

+ 6 - 3
internal/web/translation/tr-TR.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Yenileme günü",
       "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
       "renewsUsed": "Kullanılan yenileme",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Tıkanıklık Kontrolü",
+        "congestionControlHint": "Sunucu ve istemci algoritması. Sunucu değişiklikleri yeniden bağlanınca uygulanır.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "UDP İletim Modu",
+        "udpRelayModeHint": "İstemci UDP modu. Sunucu her ikisini de destekler.",
         "zeroRttHandshake": "0-RTT El Sıkışması",
         "logLevel": "Günlük Seviyesi",
-        "logLevelHint": "Çevrimiçi durumu, son görülme ve \"ilk kullanımdan sonra başlat\" sidecar'ın Info satırlarından okunur; Warn veya Error bunları bu inbound için kapatır.",
+        "logLevelHint": "Bu gelen bağlantı için en düşük günlük seviyesi.",
         "maxIdleTime": "Maksimum Boşta Kalma Süresi (sn)",
         "authTimeout": "Kimlik Doğrulama Zaman Aşımı (sn)",
         "maxUdpRelayPacketSize": "Maksimum UDP Paket Boyutu",
         "sni": "SNI",
-        "sniHint": "Sunucu Adı Belirtimi (SNI) geçersiz kılma"
+        "sniHint": "İstemci profilindeki TLS sunucu adı.",
+        "sniRequired": "Sertifika yollarını otomatik doldurmak için alan adı girin."
       },
       "tun": {
         "userLevel": "Kullanıcı Seviyesi"

+ 6 - 3
internal/web/translation/uk-UA.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Подовжувати числа",
       "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
       "renewsUsed": "Подовжень витрачено",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Контроль перевантаження",
+        "congestionControlHint": "Алгоритм сервера й клієнта. На сервері змінюється після перепідключення.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "Режим ретрансляції UDP",
+        "udpRelayModeHint": "Режим UDP для клієнта. Сервер підтримує обидва.",
         "zeroRttHandshake": "0-RTT рукостискання",
         "logLevel": "Рівень журналювання",
-        "logLevelHint": "Статус онлайн, «востаннє онлайн» і «старт після першого використання» читаються з рядків Info сайдкара; Warn або Error вимикають їх для цього інбаунда.",
+        "logLevelHint": "Мінімальний рівень повідомлень у логах цього інбаунду.",
         "maxIdleTime": "Макс. час простою (сек)",
         "authTimeout": "Таймаут автентифікації (сек)",
         "maxUdpRelayPacketSize": "Макс. розмір пакета UDP",
         "sni": "SNI",
-        "sniHint": "Перевизначення Server Name Indication"
+        "sniHint": "Ім’я сервера для TLS у профілі клієнта.",
+        "sniRequired": "Укажіть домен для автозаповнення."
       },
       "tun": {
         "userLevel": "Рівень користувача"

+ 6 - 3
internal/web/translation/vi-VN.json

@@ -966,7 +966,6 @@
       "renewOnDay": "Gia hạn vào ngày",
       "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
       "renewsUsed": "Số lần gia hạn đã dùng",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto."
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "Kiểm soát tắc nghẽn",
+        "congestionControlHint": "Thuật toán máy chủ và máy khách. Thay đổi trên máy chủ có hiệu lực khi kết nối lại.",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "Chế độ chuyển tiếp UDP",
+        "udpRelayModeHint": "Chế độ UDP của máy khách. Máy chủ hỗ trợ cả hai.",
         "zeroRttHandshake": "Bắt tay 0-RTT",
         "logLevel": "Mức ghi nhật ký",
-        "logLevelHint": "Trạng thái trực tuyến, lần trực tuyến cuối và \"bắt đầu sau lần dùng đầu tiên\" được đọc từ các dòng Info của sidecar; Warn hoặc Error sẽ tắt chúng cho inbound này.",
+        "logLevelHint": "Mức nhật ký tối thiểu cho inbound này.",
         "maxIdleTime": "Thời gian nhàn rỗi tối đa (giây)",
         "authTimeout": "Thời gian chờ xác thực (giây)",
         "maxUdpRelayPacketSize": "Kích thước gói UDP tối đa",
         "sni": "SNI",
-        "sniHint": "Ghi đè Server Name Indication"
+        "sniHint": "Tên máy chủ TLS trong cấu hình máy khách.",
+        "sniRequired": "Nhập tên miền để tự điền đường dẫn chứng chỉ."
       },
       "tun": {
         "userLevel": "Mức Người Dùng"

+ 6 - 3
internal/web/translation/zh-CN.json

@@ -966,7 +966,6 @@
       "renewOnDay": "按日期续期",
       "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
       "renewsUsed": "已用续期次数",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。"
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "拥塞控制算法",
+        "congestionControlHint": "服务器和客户端算法。服务器更改在重新连接后生效。",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "UDP 转发模式",
+        "udpRelayModeHint": "客户端 UDP 模式。服务器支持两种模式。",
         "zeroRttHandshake": "0-RTT 握手",
         "logLevel": "日志级别",
-        "logLevelHint": "在线状态、最后在线时间和“首次使用后开始”来自 sidecar 的 Info 日志行;选择 Warn 或 Error 会使此入站失去这些功能。",
+        "logLevelHint": "此入站的最低日志级别。",
         "maxIdleTime": "最大空闲时间 (秒)",
         "authTimeout": "认证超时时间 (秒)",
         "maxUdpRelayPacketSize": "最大 UDP 数据包大小",
         "sni": "SNI",
-        "sniHint": "服务器名称指示 (SNI) 覆盖"
+        "sniHint": "客户端配置中的 TLS 服务器名称。",
+        "sniRequired": "请输入域名以自动填充证书路径。"
       },
       "tun": {
         "userLevel": "用户级别"

+ 6 - 3
internal/web/translation/zh-TW.json

@@ -966,7 +966,6 @@
       "renewOnDay": "按日期續期",
       "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
       "renewsUsed": "已用續期次數",
-      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。"
     },
     "groups": {
@@ -2138,16 +2137,20 @@
       },
       "tuic": {
         "congestionControl": "壅塞控制演算法",
+        "congestionControlHint": "伺服器與用戶端演算法。伺服器變更於重新連線後生效。",
+        "profileOptions": "Client profile options",
         "alpn": "ALPN",
         "udpRelayMode": "UDP 轉發模式",
+        "udpRelayModeHint": "用戶端 UDP 模式。伺服器支援兩種模式。",
         "zeroRttHandshake": "0-RTT 握手",
         "logLevel": "日誌層級",
-        "logLevelHint": "線上狀態、最後上線時間和「首次使用後開始」來自 sidecar 的 Info 日誌行;選擇 Warn 或 Error 會使此入站失去這些功能。",
+        "logLevelHint": "此入站的最低記錄級別。",
         "maxIdleTime": "最大閒置時間 (秒)",
         "authTimeout": "認證逾時時間 (秒)",
         "maxUdpRelayPacketSize": "最大 UDP 封包大小",
         "sni": "SNI",
-        "sniHint": "伺服器名稱指示 (SNI) 覆蓋"
+        "sniHint": "用戶端設定檔中的 TLS 伺服器名稱。",
+        "sniRequired": "請輸入網域以自動填入憑證路徑。"
       },
       "tun": {
         "userLevel": "用戶級別"

+ 20 - 12
internal/web/web.go

@@ -791,15 +791,30 @@ func (s *Server) StopPanelOnly() error {
 
 func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 	s.cancel()
+	var err1 error
+	var err2 error
+	if s.httpServer != nil {
+		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+		err1 = s.httpServer.Shutdown(shutdownCtx)
+		shutdownCancel()
+	}
+	if s.cron != nil {
+		<-s.cron.Stop().Done()
+	}
 	if stopXray {
-		_ = s.xrayService.StopXray()
+		tuic.GetManager().StopAll()
+		if err := job.NewTuicJob().FlushStoppedTraffic(); err != nil {
+			logger.Warning("persist final TUIC traffic on shutdown failed:", err)
+			err2 = err
+		}
 		mtproto.GetManager().StopAll()
 		amneziawgnet.GetManager().StopAll()
-		tuic.GetManager().StopAll()
 		amneziawgnet.GetOutboundManager().StopAll()
 	}
-	if s.cron != nil {
-		s.cron.Stop()
+	if stopXray {
+		if err := s.xrayService.StopXray(); err != nil {
+			err2 = common.Combine(err2, err)
+		}
 	}
 	if s.bus != nil {
 		s.bus.Stop()
@@ -820,15 +835,8 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 	if s.wsHub != nil {
 		s.wsHub.Stop()
 	}
-	var err1 error
-	var err2 error
-	if s.httpServer != nil {
-		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
-		defer shutdownCancel()
-		err1 = s.httpServer.Shutdown(shutdownCtx)
-	}
 	if s.listener != nil {
-		err2 = s.listener.Close()
+		err1 = common.Combine(err1, s.listener.Close())
 	}
 	return common.Combine(err1, err2)
 }

+ 93 - 0
internal/web/web_tuic_shutdown_test.go

@@ -0,0 +1,93 @@
+package web
+
+import (
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"net"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/job"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestGracefulShutdownPersistsFinalTuicTraffic(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
+
+	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatalf("generate certificate key: %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, &privateKey.PublicKey, privateKey)
+	if err != nil {
+		t.Fatalf("create certificate: %v", err)
+	}
+	certificate := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyDER, err := x509.MarshalECPrivateKey(privateKey)
+	if err != nil {
+		t.Fatalf("marshal certificate key: %v", err)
+	}
+	privatePEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+
+	const (
+		inboundID = 18002
+		email     = "shutdown-tuic@x"
+	)
+	inbound := &model.Inbound{
+		Id: inboundID, Tag: "tuic-shutdown-traffic", Protocol: model.TUIC,
+		Enable: true, Listen: "127.0.0.1", Port: 0,
+		Settings: fmt.Sprintf(`{"certificate":%q,"private_key":%q,"clients":[{"uuid":"a0000000-0000-0000-0000-000000000022","password":"p","email":%q,"enable":true}]}`, certificate, privatePEM, email),
+	}
+	if err := database.GetDB().Create(inbound).Error; err != nil {
+		t.Fatalf("create TUIC inbound: %v", err)
+	}
+	if err := database.GetDB().Create(&xray.ClientTraffic{InboundId: inboundID, Email: email, Enable: true}).Error; err != nil {
+		t.Fatalf("create client traffic: %v", err)
+	}
+
+	manager := tuic.GetManager()
+	manager.StopAll()
+	manager.CollectAllTraffic()
+	t.Cleanup(manager.StopAll)
+	job.NewTuicJob().Run()
+	if !manager.AddTestTraffic(inboundID, email, 100, 200) {
+		t.Fatal("TUIC listener did not start")
+	}
+
+	if err := NewServer().Stop(); err != nil && !strings.Contains(err.Error(), "xray is not running") {
+		t.Fatalf("graceful server shutdown: %v", err)
+	}
+
+	var got xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", email).First(&got).Error; err != nil {
+		t.Fatalf("load persisted client traffic: %v", err)
+	}
+	if got.Up != 100 || got.Down != 200 {
+		t.Fatalf("shutdown persisted traffic (%d,%d), want (100,200)", got.Up, got.Down)
+	}
+	_, pending := manager.CollectAllTraffic()
+	if len(pending) != 0 {
+		t.Fatalf("final TUIC traffic remains only in manager memory: %+v", pending)
+	}
+}

+ 14 - 11
internal/xray/client_traffic.go

@@ -3,17 +3,20 @@ package xray
 // ClientTraffic represents traffic statistics and limits for a specific client.
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
-	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
-	InboundId  int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
-	Enable     bool   `json:"enable" form:"enable" example:"true"`
-	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
-	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
-	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
-	Up         int64  `json:"up" form:"up" example:"1048576"`
-	Down       int64  `json:"down" form:"down" example:"2097152"`
-	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
-	Total      int64  `json:"total" form:"total" example:"10737418240"`
-	Reset      int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
+	Id            int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
+	InboundId     int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
+	Enable        bool   `json:"enable" form:"enable" example:"true"`
+	Email         string `json:"email" form:"email" gorm:"unique" example:"user1"`
+	UUID          string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
+	TuicTrafficID int    `json:"-" gorm:"-"`
+	TuicUUID      string `json:"-" gorm:"-"`
+	TuicInboundId int    `json:"-" gorm:"-"`
+	SubId         string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
+	Up            int64  `json:"up" form:"up" example:"1048576"`
+	Down          int64  `json:"down" form:"down" example:"2097152"`
+	ExpiryTime    int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
+	Total         int64  `json:"total" form:"total" example:"10737418240"`
+	Reset         int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
 	// ResetDay renews on that day of each calendar month instead of every
 	// Reset days; 0 disables monthly renewal.
 	ResetDay int `json:"resetDay" form:"resetDay" gorm:"default:0" example:"0"`

+ 3 - 1
main.go

@@ -207,7 +207,9 @@ func runWebServer() {
 			tgbot.StopBot()
 			// ------------------------------------------------------------
 
-			_ = server.Stop()
+			if err := server.Stop(); err != nil {
+				logger.Error("Server shutdown failed:", err)
+			}
 			_ = subServer.Stop()
 			log.Println("Shutting down servers.")
 			return

+ 2 - 3
update.sh

@@ -1102,6 +1102,8 @@ update_x-ui() {
         echo -e "${green}Removing old README and LICENSE file...${plain}"
         rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1
         rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1
+        rm ${xui_folder}/bin/tuic-server -f > /dev/null 2>&1
+        rm ${xui_folder}/bin/tuic -rf > /dev/null 2>&1
     else
         rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
         _fail "ERROR: x-ui not installed."
@@ -1138,9 +1140,6 @@ update_x-ui() {
     elif [[ -f bin/mtg-linux-$(arch) ]]; then
         chmod +x bin/mtg-linux-$(arch) > /dev/null 2>&1
     fi
-    if [[ -f bin/tuic-server ]]; then
-        chmod +x bin/tuic-server > /dev/null 2>&1
-    fi
 
     echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
     local xui_script_temp="/usr/bin/x-ui-temp.$$"