소스 검색

Feature/tuic v5 (#6337)

* Feat(tuic): Implement native TUIC v5 protocol support via Rust sidecar daemon

- Add internal/tuic package for official tuic-server sidecar lifecycle management, configuration generation, and graceful process control
- Bridge decrypted TUIC QUIC traffic into loopback Xray SOCKS5 inbounds (63200+id) for traffic accounting, statistics, and routing rules
- Implement periodic reconciliation job (cadence @every 10s) and immediate runtime synchronization on inbound/client mutations
- Add TUIC inbound & multi-user client settings (UUID + Password authentication) in Web UI with SNI auto-fill and panel certificate loader
- Integrate tuic:// subscription links and Clash.Meta (Mihomo) proxy generation for TUIC
- Update install.sh to automatically download and install official tuic-server release for x86_64, aarch64, and armv7
- Add full localization for TUIC protocol across all 13 supported languages

* Feat(install): Support custom repository and branch in install and update scripts

* Ci(release): Enable publish-dev for feature branch and workflow dispatch

* Feat(sub): Add TUIC to subscription resolution and client QR config generator

- Add 'tuic' to getInboundsBySubId SQL allowlist to resolve TUIC inbounds in subscriptions and sub links
- Enhance buildTuicProxy in Clash subscription generator with robust host and credentials resolution
- Add tuicConfig.ts to generate standalone Clash/Mihomo YAML configuration
- Add dedicated TUIC Config tab in ClientQrModal with QR code and .yaml download button
- Add localization keys for TUIC config across all 13 supported languages

* Fix(tuic): Exclude TUIC from native Xray inbounds and strip udp_relay_mode from server config

- Exclude model.TUIC from native Xray inbounds in GetXrayConfig to prevent Xray startup failure
- Remove udp_relay_mode from tuic-server JSON configuration builder
- Update install.sh to install tuic-server binary to both xui_folder/bin and /usr/local/bin

* Fix(install): Fallback to dev-latest when releases/latest is not present on fork

* Feat(tuic): Add real-time online status and LastOnline tracking for TUIC clients

- Track client activity by mapping client UUID in tuic-server logs to email
- Integrate TUIC active clients into XrayTrafficJob to refresh local online clients
- Bump LastOnline timestamp in database and broadcast live online status over WebSocket

* Feat(tuic): Implement real-time traffic statistics and live speed reporting for TUIC

- Collect precise I/O traffic deltas for tuic-server child processes via /proc/<pid>/io
- Aggregate and attribute TUIC traffic deltas per client in tuic Manager
- Integrate TUIC traffic deltas into XrayTrafficJob to update database and broadcast live speed

* Feat(tuic): Finalize TUIC v5 integration with 1:1 traffic counting and orphan process cleanup
- Use exact 1:1 byte delta accounting from /proc/<pid>/io
- Add killStrayTuicProcesses to terminate orphan sidecars on panel startup
- Fully integrate TUIC with subscriptions, live speed meter, and all 13 locales

* Feat(frontend): Polish TUIC UI, support bulk operations, and update translations

- Align TUIC inbound certificate form with standard 3X-UI layout (Set Default Cert, Clear)
- Remove extra subtitle hint text from TUIC inbound form fields
- Support TUIC in client bulk attach/detach and bulk add modals
- Add TUIC badge color to client info modal, clients table, and host list
- Update password tooltip across all 13 locales to include TUIC
- Remove obsolete dead translation keys across all 13 locales

* Chore(ci): Finalize TUIC v5 bundling across release workflow, Docker, and scripts

* Feat(openapi): Update OpenAPI generator and schemas for TUIC types

* Fix(backend): Address core review findings for TUIC types, port checks, and xray bridge

* Refactor(traffic): Isolate proc reading with build tags and decouple TUIC metering into TuicJob

* Feat(client): Add TuicServer to InboundOption, fix config export and clean share links

* Fix(frontend): Register TUIC in multi-user helpers, tracked protocols, and tag derivation

* Chore(openapi): Re-generate OpenAPI specification and sync Zod schemas

* Chore(scripts): Add Alpine musl binaries, 386 and Windows packaging, and anchor pkill

* Fix(review): Remove stale import, correct binary names, switch to musl, and drop unreachable relay gate

* Feat(frontend): Show share link in Inbound Info and display UDP tag for TUIC

* Docs: Add TUIC v5 configuration guide and link specifications

* Docs(tuic): Correct Clash Meta configuration parameter to reduce-rtt

* Fix(tuic): Generate client credentials on copy, enforce ID/password validation, and add i386 to DockerInit

* Fix(tuic): drop unused relay, fix traffic accounting, and honor host endpoints

- Drop unused loopback SOCKS relay and eliminate port collision with AmneziaWG
- Correct inbound traffic calculation without double-counting
- Drop heuristic client traffic division while retaining online tracking
- Support externalProxy host fan-out and conditional parameters in share links
- Scope orphan process termination to managed config directory

* Fix(tuic): enforce client quotas, decouple Xray restart, and sync openapi schemas

- Regenerate OpenAPI, Zod schemas, and TypeScript types without route_through_xray
- Populate clientTraffics in TuicJob to enforce client quotas and first-use expiry
- Split process I/O delta into up and down in Process.CollectTraffic
- Remove SetNeedRestart from updateTuicInbound to prevent Xray session drops
- Use InstanceFromInbound for default ALPN and UDP relay mode in tuic:// share links
- Support allow_insecure on externalProxy host endpoints without parameter collision

* Fix(tuic): attribute client traffic only on single-user inbounds and sync link defaults

- Attribute I/O deltas to the client only when the inbound has exactly one configured client, avoiding false billing and disablings on multi-user inbounds
- Aggregate client traffic by email in TuicJob so clients on multiple inbounds don't lose deltas
- Match frontend genTuicLink defaults for alpn and udp_relay_mode with backend subscription links

* Fix(tuic): gate client traffic by total sidecar clients and require client email

* Fix(tuic): enforce inbound-only traffic limits and disable client totalGB

* fix(tuic): restore delayed start, remove client totalGB rejection, and document linux-only limits

* fix(tuic): anchor pkill, fix io baseline/split, escape yaml, and deduplicate start errors

* fix(tuic): prevent traffic double-counting, ensure info log level for delayed start, and broaden pkill matching

* fix(tuic): address review round 11 findings

- internal/sub/json_service: skip tuic protocol in json subscription to prevent direct routing leak
- internal/sub/clash_service: honor externalProxy/host row allowInsecure, sni, and alpn in buildTuicProxy
- internal/web/runtime: decouple tuic inbound add/delete from xray restart
- internal/tuic/config: restore user log-level options (warn, error) without forced info clamp
- frontend/src/lib/xray/inbound-link: fix duplicate remark suffix and apply externalProxy TLS overrides
- frontend/src/schemas/protocols/stream/external-proxy: propagate allowInsecure through host mapping
- tests: add coverage for json sub skip, clash proxy overrides, and link generation

* fix(tuic): meter inbound traffic through a UDP relay and bracket IPv6 binds

Review repairs on the TUIC v5 sidecar integration:

- Inbound traffic was read from the sidecar's /proc/<pid>/io rchar, but
  the kernel only counts read()/write() there and tuic-server moves its
  sockets with recvfrom/recvmmsg/sendmmsg/sendto, so an inbound's up/down
  stayed at 0 forever and inbound total limits never tripped (measured:
  12 MiB relayed, rchar delta 0). The panel now owns the inbound's public
  UDP port with a small relay and runs tuic-server behind it on a loopback
  port, counting up/down exactly on every OS. tuic-server therefore logs
  127.0.0.1 as every client's address; per-client attribution stays
  unsupported since QUIC is opaque.
- Instance.BindTo formatted an IPv6 listen address as ":::8443", which
  tuic-server rejects with "invalid socket address syntax", so an inbound
  listening on "::" or any IPv6 literal never started. It now uses
  net.JoinHostPort; IPv4 output is unchanged.
- The log level is passed to the sidecar as chosen. Online status,
  last-online and delayed start are read from its Info lines, so the Log
  Level field now says that Warn and Error switch them off for the
  inbound, and the docs say the same.
- Drop two frontend tests that only exercised a getter and a set lookup,
  and strip the trailing blank line that made gofumpt fail on two of the
  new Go test files.

* fix(tuic): harden tag updates, runtime routing, and relay stability

---------

Co-authored-by: poise52 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
amae 6 시간 전
부모
커밋
6d96accd63
100개의 변경된 파일4139개의 추가작업 그리고 52개의 파일을 삭제
  1. 25 0
      .github/workflows/release.yml
  2. 22 0
      DockerInit.sh
  3. 1 0
      docs/content/docs/en/config/inbounds.mdx
  4. 1 0
      docs/content/docs/en/config/meta.json
  5. 1 0
      docs/content/docs/en/config/share-links.mdx
  6. 113 0
      docs/content/docs/en/config/tuic.mdx
  7. 1 0
      docs/content/docs/ru/config/inbounds.mdx
  8. 1 0
      docs/content/docs/ru/config/meta.json
  9. 1 0
      docs/content/docs/ru/config/share-links.mdx
  10. 112 0
      docs/content/docs/ru/config/tuic.mdx
  11. 83 1
      docs/public/openapi.json
  12. 83 1
      frontend/public/openapi.json
  13. 21 0
      frontend/src/generated/examples.ts
  14. 82 1
      frontend/src/generated/schemas.ts
  15. 21 0
      frontend/src/generated/types.ts
  16. 24 1
      frontend/src/generated/zod.ts
  17. 2 0
      frontend/src/lib/hosts/host-link.ts
  18. 40 1
      frontend/src/lib/xray/inbound-defaults.ts
  19. 3 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  20. 77 0
      frontend/src/lib/xray/inbound-link.ts
  21. 7 1
      frontend/src/lib/xray/inbound-tag.ts
  22. 6 0
      frontend/src/lib/xray/link-label.tsx
  23. 3 1
      frontend/src/lib/xray/protocol-capabilities.ts
  24. 4 0
      frontend/src/models/dbinbound.ts
  25. 1 0
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  26. 1 0
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  27. 17 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  28. 20 1
      frontend/src/pages/clients/ClientFormModal.tsx
  29. 1 0
      frontend/src/pages/clients/ClientInfoModal.tsx
  30. 36 2
      frontend/src/pages/clients/ClientQrModal.tsx
  31. 1 0
      frontend/src/pages/clients/ClientsPage.tsx
  32. 64 0
      frontend/src/pages/clients/tuicConfig.ts
  33. 1 0
      frontend/src/pages/hosts/HostList.tsx
  34. 6 1
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  35. 1 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  36. 252 0
      frontend/src/pages/inbounds/form/protocols/tuic.tsx
  37. 1 0
      frontend/src/pages/inbounds/info/helpers.ts
  38. 1 0
      frontend/src/pages/inbounds/list/helpers.ts
  39. 1 0
      frontend/src/pages/inbounds/list/types.ts
  40. 1 1
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  41. 1 0
      frontend/src/pages/inbounds/useInbounds.ts
  42. 18 0
      frontend/src/schemas/client.ts
  43. 2 0
      frontend/src/schemas/primitives/protocol.ts
  44. 3 0
      frontend/src/schemas/protocols/inbound/index.ts
  45. 60 0
      frontend/src/schemas/protocols/inbound/tuic.ts
  46. 1 0
      frontend/src/schemas/protocols/stream/external-proxy.ts
  47. 1 1
      frontend/src/test/client-form-modal.test.tsx
  48. 8 0
      frontend/src/test/host-link.test.ts
  49. 19 0
      frontend/src/test/inbound-defaults.test.ts
  50. 152 0
      frontend/src/test/inbound-link.test.ts
  51. 5 0
      frontend/src/test/inbound-tag.test.ts
  52. 10 0
      frontend/src/test/link-label.test.ts
  53. 63 0
      frontend/src/test/tuic-client-config.test.ts
  54. 35 1
      install.sh
  55. 2 1
      internal/database/model/model.go
  56. 58 0
      internal/sub/clash_service.go
  57. 1 1
      internal/sub/json_service.go
  58. 11 0
      internal/sub/json_service_test.go
  59. 79 1
      internal/sub/service.go
  60. 190 0
      internal/sub/service_tuic_test.go
  61. 94 0
      internal/tuic/config.go
  62. 87 0
      internal/tuic/config_test.go
  63. 231 0
      internal/tuic/manager.go
  64. 95 0
      internal/tuic/manager_test.go
  65. 87 0
      internal/tuic/orphans_linux.go
  66. 5 0
      internal/tuic/orphans_other.go
  67. 292 0
      internal/tuic/process.go
  68. 7 0
      internal/tuic/process_other.go
  69. 67 0
      internal/tuic/process_windows.go
  70. 196 0
      internal/tuic/relay.go
  71. 127 0
      internal/tuic/relay_test.go
  72. 249 0
      internal/tuic/types.go
  73. 124 0
      internal/tuic/types_test.go
  74. 84 0
      internal/web/job/tuic_job.go
  75. 40 2
      internal/web/runtime/local.go
  76. 7 0
      internal/web/service/client_crud.go
  77. 16 0
      internal/web/service/client_inbound_apply.go
  78. 50 6
      internal/web/service/inbound.go
  79. 3 0
      internal/web/service/inbound_clients.go
  80. 66 0
      internal/web/service/inbound_clients_tuic_test.go
  81. 4 0
      internal/web/service/inbound_traffic_apply.go
  82. 87 0
      internal/web/service/inbound_tuic.go
  83. 1 1
      internal/web/service/port_conflict.go
  84. 1 1
      internal/web/service/xray.go
  85. 19 2
      internal/web/translation/ar-EG.json
  86. 18 1
      internal/web/translation/en-US.json
  87. 19 2
      internal/web/translation/es-ES.json
  88. 19 2
      internal/web/translation/fa-IR.json
  89. 19 2
      internal/web/translation/id-ID.json
  90. 19 2
      internal/web/translation/ja-JP.json
  91. 19 2
      internal/web/translation/pt-BR.json
  92. 18 1
      internal/web/translation/ru-RU.json
  93. 19 2
      internal/web/translation/tr-TR.json
  94. 19 2
      internal/web/translation/uk-UA.json
  95. 19 2
      internal/web/translation/vi-VN.json
  96. 20 3
      internal/web/translation/zh-CN.json
  97. 19 2
      internal/web/translation/zh-TW.json
  98. 7 0
      internal/web/web.go
  99. 4 0
      tools/openapigen/main.go
  100. 4 0
      update.sh

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

@@ -180,6 +180,28 @@ jobs:
               rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
               ;;
           esac
+          case "${{ matrix.platform }}" in
+            amd64)
+              curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-unknown-linux-musl"
+              mv "tuic-server-1.0.0-x86_64-unknown-linux-musl" "tuic-server"
+              chmod +x "tuic-server"
+              ;;
+            arm64)
+              curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-aarch64-unknown-linux-musl"
+              mv "tuic-server-1.0.0-aarch64-unknown-linux-musl" "tuic-server"
+              chmod +x "tuic-server"
+              ;;
+            armv7)
+              curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-armv7-unknown-linux-musleabihf"
+              mv "tuic-server-1.0.0-armv7-unknown-linux-musleabihf" "tuic-server"
+              chmod +x "tuic-server"
+              ;;
+            386)
+              curl -sfLRO $CURL_RETRY "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-i686-unknown-linux-musl"
+              mv "tuic-server-1.0.0-i686-unknown-linux-musl" "tuic-server"
+              chmod +x "tuic-server"
+              ;;
+          esac
           cd ../..
 
       - name: Package
@@ -312,6 +334,9 @@ jobs:
           Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
           Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
 
+          # TUIC sidecar for Windows
+          curl.exe -sfLRo "tuic-server-windows-amd64.exe" --retry 5 --retry-all-errors --retry-delay 3 "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-pc-windows-msvc.exe"
+
           cd ..
           Copy-Item -Path ..\windows_files\* -Destination . -Recurse
           cd ..

+ 22 - 0
DockerInit.sh

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

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

@@ -64,6 +64,7 @@ The inbound editor accepts these protocols:
 | **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener.                                        |
 | **Dokodemo-door / Tunnel** | Port forwarding / traffic redirect.                                 |
 | **MTProto**            | Telegram MTProto proxy, served by a bundled `mtg` process (not Xray).    |
+| **TUIC**               | QUIC-based proxy protocol (v5), served by a bundled `tuic-server` process. See [TUIC](/docs/config/tuic). |
 
 <Callout type="info">
   Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol

+ 1 - 0
docs/content/docs/en/config/meta.json

@@ -7,6 +7,7 @@
     "inbounds",
     "reality",
     "amneziawg",
+    "tuic",
     "transports",
     "clients",
     "subscription",

+ 1 - 0
docs/content/docs/en/config/share-links.mdx

@@ -17,6 +17,7 @@ as v2rayNG, Hiddify, and Mihomo import these links to configure themselves.
 | `ss://`        | `ss://<userinfo>@<host>:<port>?<params>#<remark>` (SIP002; Shadowsocks-2022 uses percent-encoded userinfo) |
 | `hysteria2://` | `hysteria2://<auth>@<host>:<port>?<params>#<remark>`            |
 | `tg://proxy`   | `tg://proxy?server=…&port=…&secret=…` (MTProto)                 |
+| `tuic://`      | `tuic://<uuid>:<password>@<host>:<port>?<params>#<remark>` (TUIC v5) |
 
 The query parameters carry the transport and security settings — `security`,
 `sni`, `fp`, `pbk`, `sid`, `spx`, `flow`, `type`, `path`, `host`, `alpn`, and

+ 113 - 0
docs/content/docs/en/config/tuic.mdx

@@ -0,0 +1,113 @@
+---
+title: TUIC
+description: Set up a TUIC inbound in 3x-ui — QUIC congestion control, 0-RTT handshakes, and multi-user authentication.
+icon: Zap
+---
+
+**TUIC** (v5) is a proxy protocol built directly on top of the **QUIC** (HTTP/3) transport
+layer. It uses 0-RTT handshakes, connection multiplexing without head-of-line blocking,
+and custom congestion control algorithms to maintain stable connections over lossy or
+unstable networks.
+
+<Callout type="info">
+  Like MTProto, TUIC runs as a **managed sidecar process** (`tuic-server` 1.0.0,
+  written in Rust) rather than inside Xray-core. The panel manages the binary
+  lifecycle, generates configurations, monitors process health, and tracks
+  inbound traffic and client online presence.
+</Callout>
+
+## Key settings
+
+### Server & QUIC parameters
+
+| Field | Description |
+| --- | --- |
+| **Port** | UDP port for incoming client QUIC connections. |
+| **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. |
+| **SNI** | Server Name Indication matching your TLS certificate domain name. |
+| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. |
+| **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
+| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. |
+| **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. |
+| **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). |
+| **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). |
+| **Max Packet Size** | Maximum UDP relay packet size in bytes (default: `1500`). |
+
+## Set it up in the panel
+
+<Steps>
+
+<Step>
+### Add an inbound
+
+Create a new inbound and choose protocol **TUIC**. Assign a UDP port (e.g. `8443` or `443`).
+</Step>
+
+<Step>
+### Select TLS certificate
+
+Provide the certificate file path and private key file path (or paste their contents). Make sure the configured SNI matches the certificate domain.
+</Step>
+
+<Step>
+### Configure QUIC options
+
+The panel fills recommended defaults (`bbr`, `h3`, `native` UDP relay). Adjust timeouts or enable **Zero-RTT Handshake** if desired.
+</Step>
+
+<Step>
+### Add clients
+
+Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. The panel automatically generates secure random credentials when creating clients.
+</Step>
+
+<Step>
+### Export and connect
+
+Copy the client's share link (`tuic://…`) or open the **QR modal** to download a ready-to-use **Clash / Mihomo YAML** configuration.
+</Step>
+
+</Steps>
+
+## Client support & configuration
+
+TUIC v5 is supported by modern proxy clients including **Clash Verge Rev**, **Mihomo**, **Flclash**, **sing-box**, and **v2rayN**.
+
+### Clash / Mihomo configuration
+
+The panel provides automatic YAML export for Clash/Mihomo in the client QR modal:
+
+```yaml title="clash-tuic.yaml"
+proxies:
+  - name: "3x-ui-tuic"
+    type: tuic
+    server: vpn.example.com
+    port: 8443
+    uuid: 8a47f2b1-5e8c-4a3d-9b1e-7f6c5d4a3b2a
+    password: secure-random-password
+    alpn:
+      - h3
+    sni: vpn.example.com
+    congestion-controller: bbr
+    udp-relay-mode: native
+    reduce-rtt: false
+    skip-cert-verify: false
+```
+
+### Share link format
+
+TUIC share links use standard URI formatting:
+
+```text
+tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark
+```
+
+## Architecture & Notes
+
+<Callout type="info">
+  - **Standalone sidecar**: The panel ships pre-compiled `tuic-server` musl binaries on Linux (amd64, arm64, armv7, 386) and executable for Windows.
+  - **Traffic accounting & limits**: The panel owns the inbound's public UDP port with a small relay and runs `tuic-server` behind it on a loopback port, so the inbound's upload and download bytes are counted exactly on every OS and enforced at the **inbound level** (`inbounds.total`); `tuic-server` therefore logs `127.0.0.1` as every client's address. Because upstream `tuic-server` does not provide an internal per-user metrics API, individual client traffic limits (`totalGB`) are not supported for TUIC clients. Client access can be controlled via expiration timestamps (`expiryTime`) and manual enable/disable toggles.
+  - **Online status & "start after first use"**: The panel detects a client's activity from the sidecar's Info log lines (they carry the client UUID), so those features need the inbound's log level at `info` or `debug`; `warn` and `error` silence them.
+  - **Client updates & connections**: Because upstream `tuic-server` lacks dynamic user reload APIs, client modifications (adding, updating, or disabling clients) restart the sidecar process and momentarily reset active connections.
+  - **Deployment**: Because TUIC operates via a host sidecar process, TUIC inbounds are panel-local (main instance).
+</Callout>

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

@@ -64,6 +64,7 @@ icon: ArrowDownToLine
 | **Mixed (SOCKS/HTTP)** | Совмещённый слушатель SOCKS + HTTP.                                       |
 | **Dokodemo-door / Tunnel** | Перенаправление портов / перенаправление трафика.                    |
 | **MTProto**            | Прокси Telegram MTProto, обслуживаемый встроенным процессом `mtg` (не Xray). |
+| **TUIC**               | Протокол проксирования на базе QUIC (v5), обслуживаемый встроенным процессом `tuic-server`. См. [TUIC](/docs/config/tuic). |
 
 <Callout type="info">
   Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria`

+ 1 - 0
docs/content/docs/ru/config/meta.json

@@ -6,6 +6,7 @@
     "ssl-certificates",
     "inbounds",
     "reality",
+    "tuic",
     "transports",
     "clients",
     "subscription",

+ 1 - 0
docs/content/docs/ru/config/share-links.mdx

@@ -17,6 +17,7 @@ icon: Link
 | `ss://`        | `ss://<userinfo>@<host>:<port>?<params>#<remark>` (SIP002; Shadowsocks-2022 использует userinfo с процентным кодированием) |
 | `hysteria2://` | `hysteria2://<auth>@<host>:<port>?<params>#<remark>`            |
 | `tg://proxy`   | `tg://proxy?server=…&port=…&secret=…` (MTProto)                 |
+| `tuic://`      | `tuic://<uuid>:<password>@<host>:<port>?<params>#<remark>` (TUIC v5) |
 
 Параметры запроса несут настройки транспорта и безопасности — `security`,
 `sni`, `fp`, `pbk`, `sid`, `spx`, `flow`, `type`, `path`, `host`, `alpn` и

+ 112 - 0
docs/content/docs/ru/config/tuic.mdx

@@ -0,0 +1,112 @@
+---
+title: TUIC
+description: Настройка входящего подключения TUIC в 3x-ui — параметры перегрузок QUIC, 0-RTT рукопожатия и многопользовательская аутентификация.
+icon: Zap
+---
+
+**TUIC** (v5) — это протокол проксирования, работающий поверх транспортного уровня **QUIC** (HTTP/3).
+Он использует 0-RTT рукопожатия, мультиплексирование соединений без блокировки начала очереди
+и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
+
+<Callout type="info">
+  Как и MTProto, TUIC работает как **изолированный процесс-сайдкар** (`tuic-server` 1.0.0,
+  написан на Rust), а не внутри Xray-core. Панель управляет жизненным циклом бинарника,
+  генерирует конфигурации, отслеживает его состояние, фиксирует общий трафик инбаунда
+  и онлайн-активность клиентов.
+</Callout>
+
+## Ключевые параметры
+
+### Параметры сервера и QUIC
+
+| Поле | Описание |
+| --- | --- |
+| **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
+| **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
+| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. |
+| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. |
+| **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
+| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. |
+| **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
+| **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
+| **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
+| **Максимальный размер пакета** | Максимальный размер пакета UDP-релея в байтах (по умолчанию: `1500`). |
+
+## Настройка в панели
+
+<Steps>
+
+<Step>
+### Добавьте инбаунд
+
+Создайте новый инбаунд и выберите протокол **TUIC**. Задайте UDP-порт (например, `8443` или `443`).
+</Step>
+
+<Step>
+### Укажите TLS-сертификат
+
+Укажите пути к файлам сертификата и приватного ключа (или вставьте их содержимое напрямую). Убедитесь, что поле SNI совпадает с доменом сертификата.
+</Step>
+
+<Step>
+### Настройте параметры QUIC
+
+Панель автоматически подставляет рекомендованные настройки (`bbr`, `h3`, `native`). При необходимости настройте таймауты или включите **Zero-RTT Handshake**.
+</Step>
+
+<Step>
+### Добавьте клиентов
+
+Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Панель автоматически генерирует надёжные случайные данные при создании клиента.
+</Step>
+
+<Step>
+### Экспортируйте и подключитесь
+
+Скопируйте ссылку `tuic://…` или откройте **окно QR-кода**, чтобы скачать готовый конфигурационный файл **Clash / Mihomo YAML**.
+</Step>
+
+</Steps>
+
+## Поддержка клиентами и конфигурация
+
+TUIC v5 поддерживается всеми популярными клиентами, включая **Clash Verge Rev**, **Mihomo**, **Flclash**, **sing-box** и **v2rayN**.
+
+### Конфигурация Clash / Mihomo
+
+Панель предоставляет автоматический экспорт в формат YAML прямо в окне QR-кода клиента:
+
+```yaml title="clash-tuic.yaml"
+proxies:
+  - name: "3x-ui-tuic"
+    type: tuic
+    server: vpn.example.com
+    port: 8443
+    uuid: 8a47f2b1-5e8c-4a3d-9b1e-7f6c5d4a3b2a
+    password: secure-random-password
+    alpn:
+      - h3
+    sni: vpn.example.com
+    congestion-controller: bbr
+    udp-relay-mode: native
+    reduce-rtt: false
+    skip-cert-verify: false
+```
+
+### Формат ссылки для обмена
+
+Ссылки TUIC используют стандартный формат URI:
+
+```text
+tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.example.com&udp_relay_mode=native&allow_insecure=0#Remark
+```
+
+## Архитектура и примечания
+
+<Callout type="info">
+  - **Автономный сайдкар**: Панель поставляется со скомпилированными статическими `musl`-бинарниками `tuic-server` для Linux (amd64, arm64, armv7, 386) и исполняемым файлом для Windows.
+  - **Учёт трафика и лимиты**: Панель сама занимает публичный UDP-порт инбаунда небольшим relay и запускает `tuic-server` за ним на loopback-порту, поэтому входящие и исходящие байты инбаунда считаются точно на любой ОС и ограничиваются на **уровне инбаунда** (`inbounds.total`); в логах `tuic-server` адресом каждого клиента будет `127.0.0.1`. Поскольку апстрим `tuic-server` не предоставляет внутреннего API метрик по отдельным пользователям, персональные квоты трафика (`totalGB`) для клиентов TUIC не поддерживаются. Доступ клиентов контролируется по сроку действия (`expiryTime`) и переключателю активности.
+  - **Статус онлайн и «старт после первого использования»**: Панель определяет активность клиента по строкам Info в логе сайдкара (в них есть UUID клиента), поэтому этим функциям нужен уровень логов `info` или `debug`; `warn` и `error` их отключают.
+  - **Изменения клиентов и соединения**: Поскольку апстрим `tuic-server` не поддерживает динамическую перезагрузку пользователей без перезапуска, любое изменение списка клиентов (добавление, редактирование или отключение) перезапускает процесс сайдкара и кратковременно сбрасывает активные соединения.
+  - **Развёртывание**: Поскольку TUIC управляется локальным процессом хоста, такие инбаунды работают локально на главной панели.
+</Callout>

+ 83 - 1
docs/public/openapi.json

@@ -2604,7 +2604,8 @@
               "tunnel",
               "tun",
               "mtproto",
-              "amneziawg"
+              "amneziawg",
+              "tuic"
             ],
             "example": "vless",
             "type": "string"
@@ -2823,6 +2824,14 @@
             "example": true,
             "type": "boolean"
           },
+          "tuicServer": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/TuicServerSettings"
+              }
+            ],
+            "nullable": true
+          },
           "wgDns": {
             "type": "string"
           },
@@ -4076,6 +4085,78 @@
         ],
         "type": "object"
       },
+      "TuicClientSettings": {
+        "properties": {
+          "email": {
+            "type": "string"
+          },
+          "password": {
+            "type": "string"
+          },
+          "uuid": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "email",
+          "password",
+          "uuid"
+        ],
+        "type": "object"
+      },
+      "TuicServerSettings": {
+        "properties": {
+          "alpn": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "authentication_timeout": {
+            "type": "integer"
+          },
+          "certificate": {
+            "type": "string"
+          },
+          "congestion_control": {
+            "type": "string"
+          },
+          "log_level": {
+            "type": "string"
+          },
+          "max_idle_time": {
+            "type": "integer"
+          },
+          "max_udp_relay_packet_size": {
+            "type": "integer"
+          },
+          "private_key": {
+            "type": "string"
+          },
+          "sni": {
+            "type": "string"
+          },
+          "udp_relay_mode": {
+            "type": "string"
+          },
+          "zero_rtt_handshake": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "alpn",
+          "authentication_timeout",
+          "certificate",
+          "congestion_control",
+          "log_level",
+          "max_idle_time",
+          "max_udp_relay_packet_size",
+          "private_key",
+          "udp_relay_mode",
+          "zero_rtt_handshake"
+        ],
+        "type": "object"
+      },
       "User": {
         "description": "User represents a user account in the 3x-ui panel.",
         "properties": {
@@ -4568,6 +4649,7 @@
                       "ssMethod": "",
                       "tag": "in-443-tcp",
                       "tlsFlowCapable": true,
+                      "tuicServer": null,
                       "wgDns": "",
                       "wgMtu": 0,
                       "wgPublicKey": ""

+ 83 - 1
frontend/public/openapi.json

@@ -2604,7 +2604,8 @@
               "tunnel",
               "tun",
               "mtproto",
-              "amneziawg"
+              "amneziawg",
+              "tuic"
             ],
             "example": "vless",
             "type": "string"
@@ -2823,6 +2824,14 @@
             "example": true,
             "type": "boolean"
           },
+          "tuicServer": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/TuicServerSettings"
+              }
+            ],
+            "nullable": true
+          },
           "wgDns": {
             "type": "string"
           },
@@ -4076,6 +4085,78 @@
         ],
         "type": "object"
       },
+      "TuicClientSettings": {
+        "properties": {
+          "email": {
+            "type": "string"
+          },
+          "password": {
+            "type": "string"
+          },
+          "uuid": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "email",
+          "password",
+          "uuid"
+        ],
+        "type": "object"
+      },
+      "TuicServerSettings": {
+        "properties": {
+          "alpn": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "authentication_timeout": {
+            "type": "integer"
+          },
+          "certificate": {
+            "type": "string"
+          },
+          "congestion_control": {
+            "type": "string"
+          },
+          "log_level": {
+            "type": "string"
+          },
+          "max_idle_time": {
+            "type": "integer"
+          },
+          "max_udp_relay_packet_size": {
+            "type": "integer"
+          },
+          "private_key": {
+            "type": "string"
+          },
+          "sni": {
+            "type": "string"
+          },
+          "udp_relay_mode": {
+            "type": "string"
+          },
+          "zero_rtt_handshake": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "alpn",
+          "authentication_timeout",
+          "certificate",
+          "congestion_control",
+          "log_level",
+          "max_idle_time",
+          "max_udp_relay_packet_size",
+          "private_key",
+          "udp_relay_mode",
+          "zero_rtt_handshake"
+        ],
+        "type": "object"
+      },
       "User": {
         "description": "User represents a user account in the 3x-ui panel.",
         "properties": {
@@ -4568,6 +4649,7 @@
                       "ssMethod": "",
                       "tag": "in-443-tcp",
                       "tlsFlowCapable": true,
+                      "tuicServer": null,
                       "wgDns": "",
                       "wgMtu": 0,
                       "wgPublicKey": ""

+ 21 - 0
frontend/src/generated/examples.ts

@@ -731,6 +731,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ssMethod": "",
     "tag": "in-443-tcp",
     "tlsFlowCapable": true,
+    "tuicServer": null,
     "wgDns": "",
     "wgMtu": 0,
     "wgPublicKey": ""
@@ -1002,6 +1003,26 @@ export const EXAMPLES: Record<string, unknown> = {
     "Tag": "inbound-443",
     "Up": 1048576
   },
+  "TuicClientSettings": {
+    "email": "",
+    "password": "",
+    "uuid": ""
+  },
+  "TuicServerSettings": {
+    "alpn": [
+      ""
+    ],
+    "authentication_timeout": 0,
+    "certificate": "",
+    "congestion_control": "",
+    "log_level": "",
+    "max_idle_time": 0,
+    "max_udp_relay_packet_size": 0,
+    "private_key": "",
+    "sni": "",
+    "udp_relay_mode": "",
+    "zero_rtt_handshake": false
+  },
   "User": {
     "id": 0,
     "password": "",

+ 82 - 1
frontend/src/generated/schemas.ts

@@ -2578,7 +2578,8 @@ export const SCHEMAS: Record<string, unknown> = {
           "tunnel",
           "tun",
           "mtproto",
-          "amneziawg"
+          "amneziawg",
+          "tuic"
         ],
         "example": "vless",
         "type": "string"
@@ -2797,6 +2798,14 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": true,
         "type": "boolean"
       },
+      "tuicServer": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/TuicServerSettings"
+          }
+        ],
+        "nullable": true
+      },
       "wgDns": {
         "type": "string"
       },
@@ -4050,6 +4059,78 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "TuicClientSettings": {
+    "properties": {
+      "email": {
+        "type": "string"
+      },
+      "password": {
+        "type": "string"
+      },
+      "uuid": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "email",
+      "password",
+      "uuid"
+    ],
+    "type": "object"
+  },
+  "TuicServerSettings": {
+    "properties": {
+      "alpn": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "authentication_timeout": {
+        "type": "integer"
+      },
+      "certificate": {
+        "type": "string"
+      },
+      "congestion_control": {
+        "type": "string"
+      },
+      "log_level": {
+        "type": "string"
+      },
+      "max_idle_time": {
+        "type": "integer"
+      },
+      "max_udp_relay_packet_size": {
+        "type": "integer"
+      },
+      "private_key": {
+        "type": "string"
+      },
+      "sni": {
+        "type": "string"
+      },
+      "udp_relay_mode": {
+        "type": "string"
+      },
+      "zero_rtt_handshake": {
+        "type": "boolean"
+      }
+    },
+    "required": [
+      "alpn",
+      "authentication_timeout",
+      "certificate",
+      "congestion_control",
+      "log_level",
+      "max_idle_time",
+      "max_udp_relay_packet_size",
+      "private_key",
+      "udp_relay_mode",
+      "zero_rtt_handshake"
+    ],
+    "type": "object"
+  },
   "User": {
     "description": "User represents a user account in the 3x-ui panel.",
     "properties": {

+ 21 - 0
frontend/src/generated/types.ts

@@ -642,6 +642,7 @@ export interface InboundOption {
   ssMethod: string;
   tag: string;
   tlsFlowCapable: boolean;
+  tuicServer?: TuicServerSettings | null;
   wgDns?: string;
   wgMtu?: number;
   wgPublicKey?: string;
@@ -921,6 +922,26 @@ export interface Traffic {
   Up: number;
 }
 
+export interface TuicClientSettings {
+  email: string;
+  password: string;
+  uuid: string;
+}
+
+export interface TuicServerSettings {
+  alpn: string[];
+  authentication_timeout: number;
+  certificate: string;
+  congestion_control: string;
+  log_level: string;
+  max_idle_time: number;
+  max_udp_relay_packet_size: number;
+  private_key: string;
+  sni?: string;
+  udp_relay_mode: string;
+  zero_rtt_handshake: boolean;
+}
+
 export interface User {
   id: number;
   password: string;

+ 24 - 1
frontend/src/generated/zod.ts

@@ -631,7 +631,7 @@ export const InboundSchema = z.object({
   nodeId: z.number().int().nullable().optional(),
   originNodeGuid: z.string().optional(),
   port: z.number().int().min(0).max(65535),
-  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg']),
+  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg', 'tuic']),
   remark: z.string(),
   settings: z.unknown(),
   shareAddr: z.string(),
@@ -685,6 +685,7 @@ export const InboundOptionSchema = z.object({
   ssMethod: z.string(),
   tag: z.string(),
   tlsFlowCapable: z.boolean(),
+  tuicServer: z.lazy(() => TuicServerSettingsSchema).nullable().optional(),
   wgDns: z.string().optional(),
   wgMtu: z.number().int().optional(),
   wgPublicKey: z.string().optional(),
@@ -983,6 +984,28 @@ export const TrafficSchema = z.object({
 });
 export type Traffic = z.infer<typeof TrafficSchema>;
 
+export const TuicClientSettingsSchema = z.object({
+  email: z.string(),
+  password: z.string(),
+  uuid: z.string(),
+});
+export type TuicClientSettings = z.infer<typeof TuicClientSettingsSchema>;
+
+export const TuicServerSettingsSchema = z.object({
+  alpn: z.array(z.string()),
+  authentication_timeout: z.number().int(),
+  certificate: z.string(),
+  congestion_control: z.string(),
+  log_level: z.string(),
+  max_idle_time: z.number().int(),
+  max_udp_relay_packet_size: z.number().int(),
+  private_key: z.string(),
+  sni: z.string().optional(),
+  udp_relay_mode: z.string(),
+  zero_rtt_handshake: z.boolean(),
+});
+export type TuicServerSettings = z.infer<typeof TuicServerSettingsSchema>;
+
 export const UserSchema = z.object({
   id: z.number().int(),
   password: z.string(),

+ 2 - 0
frontend/src/lib/hosts/host-link.ts

@@ -20,6 +20,7 @@ export type HostLinkInput = Pick<
   | 'overrideSniFromAddress'
   | 'keepSniBlank'
   | 'vlessRoute'
+  | 'allowInsecure'
 >;
 
 // hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
@@ -54,6 +55,7 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
     verifyPeerCertByName: host.verifyPeerCertByName || undefined,
     echConfigList: host.echConfigList || undefined,
     vlessRoute: host.vlessRoute || undefined,
+    allowInsecure: host.allowInsecure || undefined,
   };
 }
 

+ 40 - 1
frontend/src/lib/xray/inbound-defaults.ts

@@ -11,6 +11,7 @@ import type {
   ShadowsocksInboundSettings,
 } from '@/schemas/protocols/inbound/shadowsocks';
 import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
+import type { TuicClient, TuicInboundSettings } from '@/schemas/protocols/inbound/tuic';
 import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
 import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
 import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
@@ -137,6 +138,22 @@ export function createDefaultHysteriaClient(seed: HysteriaClientSeed = {}): Hyst
   };
 }
 
+export interface TuicClientSeed extends ClientBaseSeed {
+  uuid?: string;
+  id?: string;
+  password?: string;
+}
+
+export function createDefaultTuicClient(seed: TuicClientSeed = {}): TuicClient {
+  const uuid = seed.uuid ?? seed.id ?? RandomUtil.randomUUID();
+  return {
+    uuid,
+    id: uuid,
+    password: seed.password ?? RandomUtil.randomSeq(10),
+    ...clientBase(seed),
+  };
+}
+
 // Inbound-settings factories. Each returns a Zod-parsable wire-shape with
 // schema defaults already applied — no class instance, no XrayCommonClass.
 // Callers (form modals via Step 4, InboundsPage clone via Step 5) call
@@ -321,6 +338,25 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
   };
 }
 
+export function createDefaultTuicInboundSettings(): TuicInboundSettings {
+  return {
+    server: {
+      certificate: '',
+      private_key: '',
+      congestion_control: 'bbr',
+      alpn: ['h3', 'spdy/3.1'],
+      udp_relay_mode: 'native',
+      zero_rtt_handshake: true,
+      log_level: 'info',
+      max_idle_time: 15,
+      authentication_timeout: 3,
+      max_udp_relay_packet_size: 1500,
+      sni: '',
+    },
+    clients: [],
+  };
+}
+
 // Protocol-aware dispatch over every inbound-settings factory. Mirrors
 // the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but
 // returns a plain Zod-parsable object instead of a class instance.
@@ -338,7 +374,8 @@ export type AnyInboundSettings =
   | TunnelInboundSettings
   | WireguardInboundSettings
   | MtprotoInboundSettings
-  | AmneziawgInboundSettings;
+  | AmneziawgInboundSettings
+  | TuicInboundSettings;
 
 export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
   switch (protocol) {
@@ -366,6 +403,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
       return createDefaultMtprotoInboundSettings();
     case 'amneziawg':
       return createDefaultAmneziawgInboundSettings();
+    case 'tuic':
+      return createDefaultTuicInboundSettings();
     default:
       return null;
   }

+ 3 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -10,6 +10,7 @@ import {
   MtprotoClientSchema,
   ShadowsocksClientSchema,
   TrojanClientSchema,
+  TuicClientSchema,
   VlessClientSchema,
   VmessClientSchema,
   WireguardClientSchema,
@@ -265,6 +266,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
       return MtprotoClientSchema;
     case 'amneziawg':
       return AmneziawgClientSchema;
+    case 'tuic':
+      return TuicClientSchema;
     default:
       return null;
   }

+ 77 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -863,6 +863,70 @@ export function genMtprotoLink(input: GenMtprotoLinkInput): string {
   return url.toString();
 }
 
+export interface GenTuicLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  remark?: string;
+  clientUuid?: string;
+  clientPassword?: string;
+  externalProxy?: ExternalProxyEntry | null;
+}
+
+export function genTuicLink(input: GenTuicLinkInput): string {
+  const {
+    inbound,
+    address,
+    port = inbound.port,
+    remark = '',
+    clientUuid = '',
+    clientPassword = '',
+    externalProxy = null,
+  } = input;
+  if (!clientUuid || !clientPassword) return '';
+
+  const rawSettings = inbound.settings as Record<string, unknown>;
+  const server = (rawSettings.server as Record<string, unknown>) ?? rawSettings;
+  const host = formatUrlHost(externalProxy?.dest || address);
+  const targetPort = externalProxy?.port || port;
+
+  const url = new URL(
+    `tuic://${encodeURIComponent(clientUuid)}:${encodeURIComponent(clientPassword)}@${host}:${targetPort}`,
+  );
+  const cc =
+    (server.congestion_control as string) || (rawSettings.congestion_control as string) || 'bbr';
+  url.searchParams.set('congestion_control', cc);
+
+  const epAlpn = externalProxyAlpn(externalProxy?.alpn);
+  const alpn =
+    epAlpn ||
+    (Array.isArray(server.alpn) && server.alpn.length > 0
+      ? (server.alpn as string[]).join(',')
+      : null) ||
+    (Array.isArray(rawSettings.alpn) && rawSettings.alpn.length > 0
+      ? (rawSettings.alpn as string[]).join(',')
+      : null) ||
+    'h3,spdy/3.1';
+  url.searchParams.set('alpn', alpn);
+
+  const sni = externalProxy?.sni || (server.sni as string) || (rawSettings.sni as string);
+  if (sni) {
+    url.searchParams.set('sni', sni);
+  }
+  const udpRelay =
+    (server.udp_relay_mode as string) || (rawSettings.udp_relay_mode as string) || 'native';
+  url.searchParams.set('udp_relay_mode', udpRelay);
+
+  const allowInsecure = externalProxy?.allowInsecure ? '1' : '0';
+  url.searchParams.set('allow_insecure', allowInsecure);
+
+  if (remark) {
+    url.hash = encodeURIComponent(remark);
+  }
+
+  return url.toString();
+}
+
 export interface GenWireguardLinkInput {
   settings: WireguardInboundSettings;
   address: string;
@@ -1306,6 +1370,7 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
 // clients, and any protocol without a clients array.
 type ClientShape = {
   id?: string;
+  uuid?: string;
   security?: VmessSecurity;
   flow?: VlessClient['flow'];
   password?: string;
@@ -1333,6 +1398,8 @@ export function getInboundClients(inbound: Inbound): ClientShape[] | null {
       return (inbound.settings.clients ?? []) as ClientShape[];
     case 'mtproto':
       return (inbound.settings.clients ?? []) as ClientShape[];
+    case 'tuic':
+      return (inbound.settings.clients ?? []) as ClientShape[];
     case 'shadowsocks': {
       const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
       return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
@@ -1424,6 +1491,16 @@ export function genLink(input: GenLinkInput): string {
       });
     case 'mtproto':
       return genMtprotoLink({ inbound, address, port, clientSecret: client.secret ?? '' });
+    case 'tuic':
+      return genTuicLink({
+        inbound,
+        address,
+        port,
+        remark,
+        clientUuid: client.uuid ?? client.id ?? '',
+        clientPassword: client.password ?? '',
+        externalProxy,
+      });
     default:
       return '';
   }

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

@@ -14,7 +14,13 @@ function inboundTransports(
   streamSettings: Record<string, unknown> | undefined,
   settings: Record<string, unknown> | undefined,
 ): TransportBits {
-  if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;
+  if (
+    protocol === 'hysteria' ||
+    protocol === 'wireguard' ||
+    protocol === 'amneziawg' ||
+    protocol === 'tuic'
+  )
+    return UDP;
 
   let bits: TransportBits = 0;
   const network = asString(streamSettings?.network);

+ 6 - 0
frontend/src/lib/xray/link-label.tsx

@@ -27,6 +27,7 @@ const PROTOCOL_LABELS: Record<string, string> = {
   wg: 'WireGuard',
   tg: 'MTProto',
   vpn: 'AmneziaWG',
+  tuic: 'TUIC',
 };
 
 const PROTOCOL_COLORS: Record<string, string> = {
@@ -39,6 +40,7 @@ const PROTOCOL_COLORS: Record<string, string> = {
   WireGuard: 'cyan',
   MTProto: 'blue',
   AmneziaWG: 'yellow',
+  TUIC: 'orange',
 };
 
 const SECURITY_COLORS: Record<string, string> = {
@@ -129,6 +131,10 @@ export function parseLinkParts(link: string): LinkParts | null {
       /* not URL-shaped, fall back to protocol only */
     }
     if (scheme === 'tg') security = 'FakeTLS';
+    if (scheme === 'tuic') {
+      network = 'quic';
+      security = 'TLS';
+    }
   }
   if (security === 'none') security = '';
   return {

+ 3 - 1
frontend/src/lib/xray/protocol-capabilities.ts

@@ -79,7 +79,9 @@ export function canEnableStream(values: { protocol: string }): boolean {
 // Xray, so the Xray sniffing block does not apply to either. Every other
 // inbound supports sniffing.
 export function canEnableSniffing(values: { protocol: string }): boolean {
-  return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg';
+  return (
+    values.protocol !== 'mtproto' && values.protocol !== 'amneziawg' && values.protocol !== 'tuic'
+  );
 }
 
 // Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected

+ 4 - 0
frontend/src/models/dbinbound.ts

@@ -177,6 +177,10 @@ export class DBInbound {
     return this.protocol === Protocols.HYSTERIA;
   }
 
+  get isTuic() {
+    return this.protocol === Protocols.TUIC;
+  }
+
   get isTunnel() {
     return this.protocol === Protocols.TUNNEL;
   }

+ 1 - 0
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -16,6 +16,7 @@ const MULTI_USER_PROTOCOLS = new Set([
   'wireguard',
   'mtproto',
   'amneziawg',
+  'tuic',
 ]);
 
 interface BulkAttachInboundsModalProps {

+ 1 - 0
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -16,6 +16,7 @@ const MULTI_USER_PROTOCOLS = new Set([
   'wireguard',
   'mtproto',
   'amneziawg',
+  'tuic',
 ]);
 
 interface BulkDetachInboundsModalProps {

+ 17 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

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

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

@@ -62,6 +62,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
   'wireguard',
   'mtproto',
   'amneziawg',
+  'tuic',
 ]);
 
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -446,6 +447,19 @@ export default function ClientFormModal({
     return ids;
   }, [inbounds]);
 
+  const tuicIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row && row.protocol === 'tuic') ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
+  const hasTuic = useMemo(
+    () => (inboundIds || []).some((id) => tuicIds.has(id)),
+    [inboundIds, tuicIds],
+  );
+
   const mtprotoDomain = useMemo(() => {
     for (const id of inboundIds || []) {
       const ib = (inbounds || []).find((row) => row.id === id);
@@ -668,6 +682,7 @@ export default function ClientFormModal({
       email: values.email.trim(),
       subId: values.subId,
       id: values.uuid,
+      uuid: values.uuid,
       password: values.password,
       auth: values.auth,
       flow: showFlow ? values.flow || '' : '',
@@ -857,7 +872,11 @@ export default function ClientFormModal({
                           <FormField
                             name="totalGB"
                             label={t('pages.clients.totalGB')}
-                            tooltip={t('pages.clients.totalGBDesc')}
+                            tooltip={
+                              hasTuic
+                                ? t('pages.clients.tuicTotalGBDesc')
+                                : t('pages.clients.totalGBDesc')
+                            }
                             transform={{ output: (v) => Number(v) || 0 }}
                           >
                             <InputNumber min={0} step={1} style={{ width: '100%' }} />

+ 1 - 0
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -44,6 +44,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   http: 'purple',
   mixed: 'lime',
   tunnel: 'orange',
+  tuic: 'orange',
 };
 
 const INBOUND_CHIP_LIMIT = 1;

+ 36 - 2
frontend/src/pages/clients/ClientQrModal.tsx

@@ -17,6 +17,7 @@ import {
   findAmneziaWGInbounds,
   isAmneziaWGClient,
 } from './amneziawgConfig';
+import { buildTuicClientConfig, findTuicInbound, isTuicClient } from './tuicConfig';
 
 interface SubSettings {
   enable: boolean;
@@ -110,8 +111,24 @@ export default function ClientQrModal({
       .filter((c) => !!c.text);
   }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
+  const tuicInbound = useMemo(() => findTuicInbound(client, inboundsById), [client, inboundsById]);
+  const tuicConfigText = useMemo(() => {
+    if (!client || !tuicInbound || !isTuicClient(client)) return '';
+    return buildTuicClientConfig(
+      client,
+      tuicInbound,
+      window.location.hostname,
+      subSettings?.publicHost ?? '',
+    );
+  }, [client, tuicInbound, subSettings?.publicHost]);
+
   const hasAnything =
-    !!subLink || !!subJsonLink || wgConfigs.length > 0 || awgConfigs.length > 0 || links.length > 0;
+    !!subLink ||
+    !!subJsonLink ||
+    wgConfigs.length > 0 ||
+    awgConfigs.length > 0 ||
+    !!tuicConfigText ||
+    links.length > 0;
 
   // The reset runs during render so the effect only carries the request.
   const openSubId = open ? (client?.subId ?? '') : '';
@@ -217,8 +234,25 @@ export default function ClientQrModal({
         children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
       });
     });
+    if (tuicConfigText) {
+      out.push({
+        key: 'tuic-config',
+        label: (
+          <Tag color="orange" style={{ margin: 0 }}>
+            {t('pages.clients.tuicConfig')}
+          </Tag>
+        ),
+        children: (
+          <QrPanel
+            value={tuicConfigText}
+            remark={client?.email || 'tuic'}
+            downloadName={`${client?.email || 'tuic'}.yaml`}
+          />
+        ),
+      });
+    }
     return out;
-  }, [subLink, subJsonLink, wgConfigs, awgConfigs, links, client?.email, t]);
+  }, [subLink, subJsonLink, wgConfigs, awgConfigs, tuicConfigText, links, client?.email, t]);
 
   // Expanding the first panel is a render-time adjustment, not a side effect.
   const firstKey = open && items.length > 0 ? items[0].key : null;

+ 1 - 0
frontend/src/pages/clients/ClientsPage.tsx

@@ -177,6 +177,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   http: 'purple',
   mixed: 'lime',
   tunnel: 'orange',
+  tuic: 'orange',
 };
 const INBOUND_CHIP_LIMIT = 1;
 // A shared empty array keeps the memoised chip cell from seeing a fresh prop for

+ 64 - 0
frontend/src/pages/clients/tuicConfig.ts

@@ -0,0 +1,64 @@
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+export function isTuicClient(client: ClientRecord | null | undefined): boolean {
+  if (!client) return false;
+  return !!(client.uuid && client.password);
+}
+
+export function findTuicInbound(
+  client: ClientRecord | null | undefined,
+  inboundsById: Record<number, InboundOption>,
+): InboundOption | undefined {
+  return (client?.inboundIds || [])
+    .map((id) => inboundsById[id])
+    .find((ib) => ib?.protocol === 'tuic');
+}
+
+export function buildTuicClientConfig(
+  client: ClientRecord,
+  inbound: InboundOption | undefined,
+  host = window.location.hostname,
+  publicHost = '',
+): string {
+  const endpointHost = resolveShareHost(
+    inbound ?? {},
+    inbound?.nodeAddress ?? '',
+    preferPublicHost(host, publicHost),
+  );
+  const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
+  const remark = [inboundName, client.email].filter(Boolean).join(' - ') || 'tuic-client';
+
+  const tuicServer = inbound?.tuicServer;
+  const alpn =
+    Array.isArray(tuicServer?.alpn) && tuicServer.alpn.length > 0
+      ? tuicServer.alpn
+      : ['h3', 'spdy/3.1'];
+  const sni = tuicServer?.sni || endpointHost;
+  const cc = tuicServer?.congestion_control || 'bbr';
+  const udpRelay = tuicServer?.udp_relay_mode || 'native';
+  const reduceRtt = tuicServer?.zero_rtt_handshake ?? true;
+
+  const yamlQuote = (v: string) => JSON.stringify(v ?? '');
+
+  const lines = [
+    `# TUIC v5 Client Configuration (Clash / Mihomo / Clash Verge)`,
+    `# ${remark}`,
+    `proxies:`,
+    `  - name: ${yamlQuote(remark)}`,
+    `    type: tuic`,
+    `    server: ${endpointHost}`,
+    `    port: ${inbound?.port || 8443}`,
+    `    uuid: ${client.uuid || ''}`,
+    `    password: ${yamlQuote(client.password || '')}`,
+    `    alpn:`,
+    ...alpn.map((a: string) => `      - ${a}`),
+    `    sni: ${/["'\\#\s]/.test(sni) ? yamlQuote(sni) : sni}`,
+    `    congestion-controller: ${cc}`,
+    `    udp-relay-mode: ${udpRelay}`,
+    `    reduce-rtt: ${reduceRtt}`,
+  ];
+
+  return lines.join('\n');
+}

+ 1 - 0
frontend/src/pages/hosts/HostList.tsx

@@ -43,6 +43,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   http: 'purple',
   mixed: 'lime',
   tunnel: 'orange',
+  tuic: 'orange',
 };
 
 export function sortHosts(hosts: HostRecord[]): HostRecord[] {

+ 6 - 1
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -63,6 +63,7 @@ import {
   MixedFields,
   MtprotoFields,
   ShadowsocksFields,
+  TuicFields,
   TunFields,
   TunnelFields,
   VlessFields,
@@ -277,7 +278,8 @@ export default function InboundFormModal({
   const hasSelectableTransport =
     protocol !== Protocols.HYSTERIA &&
     protocol !== Protocols.WIREGUARD &&
-    protocol !== Protocols.TUNNEL;
+    protocol !== Protocols.TUNNEL &&
+    protocol !== Protocols.TUIC;
 
   const wPort = useWatch({ control, name: 'port' });
   const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
@@ -797,6 +799,8 @@ export default function InboundFormModal({
         />
       )}
 
+      {protocol === Protocols.TUIC && <TuicFields />}
+
       {protocol === Protocols.TUN && <TunFields />}
 
       {protocol === Protocols.TUNNEL && <TunnelFields />}
@@ -1135,6 +1139,7 @@ export default function InboundFormModal({
                     Protocols.WIREGUARD,
                     Protocols.MTPROTO,
                     Protocols.AMNEZIAWG,
+                    Protocols.TUIC,
                   ] as string[]
                 ).includes(protocol) || isFallbackHost
                   ? [

+ 1 - 0
frontend/src/pages/inbounds/form/protocols/index.ts

@@ -8,3 +8,4 @@ export { default as MixedFields } from './mixed';
 export { default as MtprotoFields } from './mtproto';
 export { default as VlessFields } from './vless';
 export { default as AmneziawgFields } from './amneziawg';
+export { default as TuicFields } from './tuic';

+ 252 - 0
frontend/src/pages/inbounds/form/protocols/tuic.tsx

@@ -0,0 +1,252 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  AutoComplete,
+  Button,
+  Collapse,
+  Form,
+  Input,
+  InputNumber,
+  Select,
+  Space,
+  Switch,
+  message,
+} from 'antd';
+import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
+import { useFormContext, useWatch } from 'react-hook-form';
+
+import { FormField } from '@/components/form/rhf';
+import { HttpUtil } from '@/utils';
+
+export default function TuicFields() {
+  const { t } = useTranslation();
+  const { control, setValue, getValues } = useFormContext();
+  const [loadingPanelCert, setLoadingPanelCert] = useState(false);
+
+  const sni = (useWatch({ control, name: 'settings.server.sni' }) ?? '') as string;
+  const certificate = (useWatch({ control, name: 'settings.server.certificate' }) ?? '') as string;
+  const privateKey = (useWatch({ control, name: 'settings.server.private_key' }) ?? '') as string;
+  const nodeId = useWatch({ control, name: 'nodeId' }) as number | null | undefined;
+
+  const handleSniChange = (newSni: string) => {
+    setValue('settings.server.sni', newSni);
+    const cleanSni = newSni.trim();
+    if (!cleanSni) return;
+
+    const currentCert = String(getValues('settings.server.certificate') || '');
+    const currentKey = String(getValues('settings.server.private_key') || '');
+
+    if (!currentCert || currentCert.startsWith('/root/cert/')) {
+      setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
+    }
+    if (!currentKey || currentKey.startsWith('/root/cert/')) {
+      setValue('settings.server.private_key', `/root/cert/${cleanSni}/privkey.pem`);
+    }
+  };
+
+  const autofillFromSni = () => {
+    const cleanSni = (sni || '').trim();
+    if (!cleanSni) {
+      message.warning(t('pages.xray.tuic.sniHint'));
+      return;
+    }
+    setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
+    setValue('settings.server.private_key', `/root/cert/${cleanSni}/privkey.pem`);
+  };
+
+  const setCertFromPanel = async () => {
+    setLoadingPanelCert(true);
+    try {
+      const msg =
+        typeof nodeId === 'number'
+          ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
+          : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
+      if (!msg?.success) {
+        message.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
+        return;
+      }
+      const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
+      if (!obj?.webCertFile && !obj?.webKeyFile) {
+        message.warning(t('pages.inbounds.setDefaultCertEmpty'));
+        return;
+      }
+      if (obj.webCertFile) {
+        setValue('settings.server.certificate', obj.webCertFile);
+      }
+      if (obj.webKeyFile) {
+        setValue('settings.server.private_key', obj.webKeyFile);
+      }
+      message.success(t('pages.inbounds.setSuccess'));
+    } catch {
+      message.error(t('somethingWentWrong'));
+    } finally {
+      setLoadingPanelCert(false);
+    }
+  };
+
+  const certOptions = sni
+    ? [
+        { value: `/root/cert/${sni}/fullchain.pem` },
+        { value: `/etc/letsencrypt/live/${sni}/fullchain.pem` },
+        { value: '/root/cert.pem' },
+      ]
+    : [{ value: '/root/cert.pem' }];
+
+  const keyOptions = sni
+    ? [
+        { value: `/root/cert/${sni}/privkey.pem` },
+        { value: `/etc/letsencrypt/live/${sni}/privkey.pem` },
+        { value: '/root/privkey.pem' },
+      ]
+    : [{ value: '/root/privkey.pem' }];
+
+  const advancedItems = [
+    {
+      key: 'advanced',
+      label: t('pages.inbounds.advancedTitle'),
+      children: (
+        <>
+          <FormField
+            name={['settings', 'server', 'zero_rtt_handshake']}
+            label={t('pages.xray.tuic.zeroRttHandshake')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+
+          <FormField
+            name={['settings', 'server', 'log_level']}
+            label={t('pages.xray.tuic.logLevel')}
+            tooltip={t('pages.xray.tuic.logLevelHint')}
+          >
+            <Select
+              options={[
+                { label: 'Info', value: 'info' },
+                { label: 'Warn', value: 'warn' },
+                { label: 'Error', value: 'error' },
+                { label: 'Debug', value: 'debug' },
+              ]}
+            />
+          </FormField>
+
+          <FormField
+            name={['settings', 'server', 'max_idle_time']}
+            label={t('pages.xray.tuic.maxIdleTime')}
+          >
+            <InputNumber min={1} style={{ width: '100%' }} />
+          </FormField>
+
+          <FormField
+            name={['settings', 'server', 'authentication_timeout']}
+            label={t('pages.xray.tuic.authTimeout')}
+          >
+            <InputNumber min={1} style={{ width: '100%' }} />
+          </FormField>
+
+          <FormField
+            name={['settings', 'server', 'max_udp_relay_packet_size']}
+            label={t('pages.xray.tuic.maxUdpRelayPacketSize')}
+          >
+            <InputNumber min={1} style={{ width: '100%' }} />
+          </FormField>
+        </>
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <Form.Item label={t('pages.xray.tuic.sni')}>
+        <Space.Compact style={{ display: 'flex' }}>
+          <Input
+            value={sni}
+            placeholder="example.com"
+            onChange={(e) => handleSniChange(e.target.value)}
+            style={{ flex: 1 }}
+          />
+          <Button icon={<SyncOutlined />} onClick={autofillFromSni}>
+            {t('pages.inbounds.form.autoFill')}
+          </Button>
+        </Space.Compact>
+      </Form.Item>
+
+      <Form.Item label={t('pages.inbounds.publicKey')}>
+        <AutoComplete
+          value={certificate}
+          options={certOptions}
+          onChange={(v) => setValue('settings.server.certificate', v)}
+          placeholder="/root/cert.pem"
+        />
+      </Form.Item>
+
+      <Form.Item label={t('pages.inbounds.privatekey')}>
+        <AutoComplete
+          value={privateKey}
+          options={keyOptions}
+          onChange={(v) => setValue('settings.server.private_key', v)}
+          placeholder="/root/privkey.pem"
+        />
+      </Form.Item>
+
+      <Form.Item label=" ">
+        <Space>
+          <Button
+            type="primary"
+            icon={<CloudDownloadOutlined />}
+            loading={loadingPanelCert}
+            onClick={setCertFromPanel}
+          >
+            {t('pages.inbounds.setDefaultCert')}
+          </Button>
+          <Button
+            danger
+            onClick={() => {
+              setValue('settings.server.certificate', '');
+              setValue('settings.server.private_key', '');
+            }}
+          >
+            {t('clear')}
+          </Button>
+        </Space>
+      </Form.Item>
+
+      <FormField
+        name={['settings', 'server', 'congestion_control']}
+        label={t('pages.xray.tuic.congestionControl')}
+      >
+        <Select
+          options={[
+            { label: 'BBR', value: 'bbr' },
+            { label: 'CUBIC', value: 'cubic' },
+            { label: 'New Reno', value: 'new_reno' },
+          ]}
+        />
+      </FormField>
+
+      <FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
+        <Select
+          mode="tags"
+          tokenSeparators={[',']}
+          options={[
+            { label: 'h3', value: 'h3' },
+            { label: 'spdy/3.1', value: 'spdy/3.1' },
+          ]}
+        />
+      </FormField>
+
+      <FormField
+        name={['settings', 'server', 'udp_relay_mode']}
+        label={t('pages.xray.tuic.udpRelayMode')}
+      >
+        <Select
+          options={[
+            { label: 'Native (Recommended)', value: 'native' },
+            { label: 'QUIC', value: 'quic' },
+          ]}
+        />
+      </FormField>
+
+      <Collapse style={{ marginTop: 16, marginBottom: 8 }} items={advancedItems} />
+    </>
+  );
+}

+ 1 - 0
frontend/src/pages/inbounds/info/helpers.ts

@@ -17,6 +17,7 @@ const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
   Protocols.SHADOWSOCKS,
   Protocols.HYSTERIA,
   Protocols.MTPROTO,
+  Protocols.TUIC,
 ]);
 
 export function hasShareLink(protocol: string): boolean {

+ 1 - 0
frontend/src/pages/inbounds/list/helpers.ts

@@ -90,6 +90,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
     case 'mtproto':
     case 'wireguard':
     case 'amneziawg':
+    case 'tuic':
       return true;
     case 'shadowsocks':
       return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });

+ 1 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -16,6 +16,7 @@ export type ProtocolFlags = {
   isHTTP?: boolean;
   isWireguard?: boolean;
   isAmneziawg?: boolean;
+  isTuic?: boolean;
   isTunnel?: boolean;
 };
 

+ 1 - 1
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -199,7 +199,7 @@ export function useInboundColumns({
               {record.protocol}
             </Tag>,
           ];
-          if (record.isWireguard || record.isAmneziawg || record.isHysteria) {
+          if (record.isWireguard || record.isAmneziawg || record.isHysteria || record.isTuic) {
             tags.push(
               <Tag key="n" color="green">
                 UDP

+ 1 - 0
frontend/src/pages/inbounds/useInbounds.ts

@@ -67,6 +67,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
   Protocols.WIREGUARD,
   Protocols.MTPROTO,
   Protocols.AMNEZIAWG,
+  Protocols.TUIC,
 ];
 
 async function fetchSlimInbounds(): Promise<unknown[]> {

+ 18 - 0
frontend/src/schemas/client.ts

@@ -101,6 +101,23 @@ export const AwgServerOptionSchema = z
   })
   .loose();
 
+export const TuicServerOptionSchema = z
+  .object({
+    certificate: z.string().optional(),
+    congestion_control: z.string().optional(),
+    alpn: z.array(z.string()).optional(),
+    udp_relay_mode: z.string().optional(),
+    zero_rtt_handshake: z.boolean().optional(),
+    log_level: z.string().optional(),
+    max_idle_time: z.number().optional(),
+    authentication_timeout: z.number().optional(),
+    max_udp_relay_packet_size: z.number().optional(),
+    sni: z.string().optional(),
+  })
+  .loose();
+
+export type TuicServerOption = z.infer<typeof TuicServerOptionSchema>;
+
 export const InboundOptionSchema = z
   .object({
     id: z.number(),
@@ -116,6 +133,7 @@ export const InboundOptionSchema = z
     wgMtu: z.number().optional(),
     wgDns: z.string().optional(),
     awgServer: AwgServerOptionSchema.nullable().optional(),
+    tuicServer: TuicServerOptionSchema.nullable().optional(),
     mtprotoDomain: z.string().optional(),
     // Hosting node id; absent/null for this panel's own inbounds (#4997).
     nodeId: z.number().nullable().optional(),

+ 2 - 0
frontend/src/schemas/primitives/protocol.ts

@@ -13,6 +13,7 @@ export const ProtocolSchema = z.enum([
   'tun',
   'mtproto',
   'amneziawg',
+  'tuic',
 ]);
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
@@ -35,4 +36,5 @@ export const Protocols = Object.freeze({
   TUN: 'tun',
   MTPROTO: 'mtproto',
   AMNEZIAWG: 'amneziawg',
+  TUIC: 'tuic',
 });

+ 3 - 0
frontend/src/schemas/protocols/inbound/index.ts

@@ -7,6 +7,7 @@ import { MixedInboundSettingsSchema } from './mixed';
 import { MtprotoInboundSettingsSchema } from './mtproto';
 import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
 import { TrojanInboundSettingsSchema } from './trojan';
+import { TuicInboundSettingsSchema } from './tuic';
 import { TunInboundSettingsSchema } from './tun';
 import { TunnelInboundSettingsSchema } from './tunnel';
 import { VlessInboundSettingsSchema } from './vless';
@@ -20,6 +21,7 @@ export * from './mixed';
 export * from './mtproto';
 export * from './shadowsocks';
 export * from './trojan';
+export * from './tuic';
 export * from './tun';
 export * from './tunnel';
 export * from './vless';
@@ -44,5 +46,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
   z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
   z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
+  z.object({ protocol: z.literal('tuic'), settings: TuicInboundSettingsSchema }),
 ]);
 export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

+ 60 - 0
frontend/src/schemas/protocols/inbound/tuic.ts

@@ -0,0 +1,60 @@
+import { z } from 'zod';
+
+const optionalClearedInt = (schema: z.ZodNumber) =>
+  z.preprocess((v) => (v == null ? undefined : v), schema.optional());
+
+const clearedToDefault = <T extends z.ZodType>(schema: T) =>
+  z.preprocess((v) => (v == null ? undefined : v), schema);
+
+export const TuicClientSchema = z.object({
+  uuid: z.string().optional(),
+  id: z.string().optional(),
+  password: z.string().default(''),
+  email: z.string().min(1),
+  limitIp: z.number().int().min(0).default(0),
+  totalGB: z.number().int().min(0).default(0),
+  expiryTime: z.number().int().default(0),
+  enable: z.boolean().default(true),
+  tgId: z
+    .union([z.number(), z.string()])
+    .transform((v) => Number(v) || 0)
+    .default(0),
+  subId: z.string().default(''),
+  comment: z.string().default(''),
+  reset: z.number().int().min(0).default(0),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type TuicClient = z.infer<typeof TuicClientSchema>;
+
+export const TuicServerSchema = z.object({
+  certificate: z.string().default(''),
+  private_key: z.string().default(''),
+  congestion_control: z.enum(['bbr', 'cubic', 'new_reno']).default('bbr'),
+  alpn: z.array(z.string()).default(['h3', 'spdy/3.1']),
+  udp_relay_mode: z.enum(['native', 'quic']).default('native'),
+  zero_rtt_handshake: z.boolean().default(true),
+  log_level: z.enum(['info', 'warn', 'error', 'debug']).default('info'),
+  max_idle_time: clearedToDefault(z.number().int().min(1).default(15)),
+  authentication_timeout: clearedToDefault(z.number().int().min(1).default(3)),
+  max_udp_relay_packet_size: clearedToDefault(z.number().int().min(1).default(1500)),
+  sni: z.string().default(''),
+});
+export type TuicServer = z.infer<typeof TuicServerSchema>;
+
+export const TuicInboundSettingsSchema = z.object({
+  server: TuicServerSchema.optional(),
+  certificate: z.string().optional(),
+  private_key: z.string().optional(),
+  congestion_control: z.string().optional(),
+  alpn: z.array(z.string()).optional(),
+  udp_relay_mode: z.string().optional(),
+  zero_rtt_handshake: z.boolean().optional(),
+  log_level: z.string().optional(),
+  max_idle_time: optionalClearedInt(z.number().int().min(1)),
+  authentication_timeout: optionalClearedInt(z.number().int().min(1)),
+  max_udp_relay_packet_size: optionalClearedInt(z.number().int().min(1)),
+  sni: z.string().optional(),
+  clients: z.array(TuicClientSchema).default([]),
+});
+export type TuicInboundSettings = z.infer<typeof TuicInboundSettingsSchema>;

+ 1 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -26,5 +26,6 @@ export const ExternalProxyEntrySchema = z.object({
   verifyPeerCertByName: z.string().optional(),
   echConfigList: z.string().optional(),
   vlessRoute: z.string().optional(),
+  allowInsecure: z.boolean().optional(),
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 1 - 1
frontend/src/test/client-form-modal.test.tsx

@@ -52,7 +52,7 @@ describe('ClientFormModal credential tooltips', () => {
 
     await waitFor(() => {
       expect(document.body.textContent).toContain(
-        'Only used by Trojan and Shadowsocks clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
+        'Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
       );
     });
   });

+ 8 - 0
frontend/src/test/host-link.test.ts

@@ -19,6 +19,7 @@ describe('hostToExternalProxyEntry', () => {
     overrideSniFromAddress: false,
     keepSniBlank: false,
     vlessRoute: '',
+    allowInsecure: false,
   };
 
   it('maps the overlapping fields onto an external-proxy entry', () => {
@@ -60,6 +61,13 @@ describe('hostToExternalProxyEntry', () => {
     expect(hostToExternalProxyEntry({ ...base, vlessRoute: '443' }).vlessRoute).toBe('443');
     expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
   });
+
+  it('carries allowInsecure through to the entry', () => {
+    expect(hostToExternalProxyEntry({ ...base, allowInsecure: true }).allowInsecure).toBe(true);
+    expect(
+      hostToExternalProxyEntry({ ...base, allowInsecure: false }).allowInsecure,
+    ).toBeUndefined();
+  });
 });
 
 describe('withMtprotoHostEndpoints', () => {

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

@@ -9,6 +9,8 @@ import {
   createDefaultShadowsocksInboundSettings,
   createDefaultTrojanClient,
   createDefaultTrojanInboundSettings,
+  createDefaultTuicClient,
+  createDefaultTuicInboundSettings,
   createDefaultTunnelInboundSettings,
   createDefaultVlessClient,
   createDefaultVlessInboundSettings,
@@ -31,6 +33,7 @@ import {
   TrojanClientSchema,
   TrojanInboundSettingsSchema,
 } from '@/schemas/protocols/inbound/trojan';
+import { TuicClientSchema, TuicInboundSettingsSchema } from '@/schemas/protocols/inbound/tuic';
 import { TunnelInboundSettingsSchema } from '@/schemas/protocols/inbound/tunnel';
 import { VlessClientSchema, VlessInboundSettingsSchema } from '@/schemas/protocols/inbound/vless';
 import { VmessClientSchema, VmessInboundSettingsSchema } from '@/schemas/protocols/inbound/vmess';
@@ -89,6 +92,17 @@ describe('createDefaultHysteriaClient', () => {
   });
 });
 
+describe('createDefaultTuicClient', () => {
+  it('produces a Zod-valid client', () => {
+    const c = createDefaultTuicClient({
+      ...seed,
+      uuid: '11111111-2222-3333-4444-555555555555',
+      password: 'fixed-tuic-pw',
+    });
+    expect(TuicClientSchema.parse(c)).toEqual(c);
+  });
+});
+
 describe('createDefault*InboundSettings factories', () => {
   it('vless', () => {
     const s = createDefaultVlessInboundSettings();
@@ -157,6 +171,11 @@ describe('createDefault*InboundSettings factories', () => {
     expect(s.peers).toEqual([]);
     expect(s.clients).toEqual([]);
   });
+
+  it('tuic', () => {
+    const s = createDefaultTuicInboundSettings();
+    expect(TuicInboundSettingsSchema.parse(s)).toEqual(s);
+  });
 });
 
 describe('createHysteriaTlsSettingsWithDefaultCert', () => {

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

@@ -5,10 +5,12 @@ import {
   amneziawgConfigFromLink,
   genAmneziaWGConfig,
   genAmneziaWGLink,
+  genAllLinks,
   genHysteriaLink,
   genInboundLinks,
   genShadowsocksLink,
   genTrojanLink,
+  genTuicLink,
   applyVlessRoute,
   genVlessLink,
   genVmessLink,
@@ -1087,3 +1089,153 @@ describe('genVlessLink XHTTP extra compatibility', () => {
     expect(extra.sessionKey).toBe('X-Session');
   });
 });
+
+describe('genTuicLink', () => {
+  it('builds a standard tuic share link with all parameters', () => {
+    const inbound = InboundSchema.parse({
+      id: 1,
+      tag: 'tuic-test',
+      protocol: 'tuic',
+      port: 8443,
+      listen: '0.0.0.0',
+      enable: true,
+      settings: {
+        server: {
+          certificate: '/etc/cert.pem',
+          private_key: '/etc/key.pem',
+          congestion_control: 'bbr',
+          alpn: ['h3', 'spdy/3.1'],
+          udp_relay_mode: 'native',
+          zero_rtt_handshake: true,
+          sni: 'tuic.example.com',
+        },
+        clients: [
+          {
+            uuid: '11111111-2222-3333-4444-555555555555',
+            password: 'secretpassword',
+            email: 'user@tuic',
+            enable: true,
+          },
+        ],
+      },
+    });
+
+    const link = genTuicLink({
+      inbound,
+      address: 'example.com',
+      port: 8443,
+      remark: 'TUIC-Node',
+      clientUuid: '11111111-2222-3333-4444-555555555555',
+      clientPassword: 'secretpassword',
+    });
+
+    expect(link).toContain(
+      'tuic://11111111-2222-3333-4444-555555555555:[email protected]:8443',
+    );
+    expect(link).toContain('congestion_control=bbr');
+    expect(link).toContain('alpn=h3%2Cspdy%2F3.1');
+    expect(link).toContain('sni=tuic.example.com');
+    expect(link).toContain('udp_relay_mode=native');
+    expect(link).toContain('allow_insecure=0');
+    expect(link).toContain('#TUIC-Node');
+  });
+
+  it('falls back to default alpn and udp_relay_mode when server settings are empty', () => {
+    const inbound = InboundSchema.parse({
+      id: 2,
+      tag: 'tuic-default-test',
+      protocol: 'tuic',
+      port: 8443,
+      listen: '0.0.0.0',
+      enable: true,
+      settings: {
+        clients: [
+          {
+            uuid: '11111111-2222-3333-4444-555555555555',
+            password: 'secretpassword',
+            email: 'user@tuic',
+            enable: true,
+          },
+        ],
+      },
+    });
+
+    const link = genTuicLink({
+      inbound,
+      address: 'example.com',
+      port: 8443,
+      remark: 'TUIC-Default',
+      clientUuid: '11111111-2222-3333-4444-555555555555',
+      clientPassword: 'secretpassword',
+    });
+
+    expect(link).toContain('congestion_control=bbr');
+    expect(link).toContain('alpn=h3%2Cspdy%2F3.1');
+    expect(link).toContain('udp_relay_mode=native');
+    expect(link).toContain('allow_insecure=0');
+  });
+
+  it('applies externalProxy overrides (sni, alpn, allow_insecure) and does not duplicate remark', () => {
+    const inbound = InboundSchema.parse({
+      id: 3,
+      tag: 'tuic-ep-test',
+      protocol: 'tuic',
+      port: 8443,
+      listen: '0.0.0.0',
+      enable: true,
+      settings: {
+        server: {
+          certificate: '/etc/cert.pem',
+          private_key: '/etc/key.pem',
+          congestion_control: 'bbr',
+          alpn: ['h3'],
+          sni: 'default.example.com',
+        },
+        clients: [
+          {
+            uuid: '11111111-2222-3333-4444-555555555555',
+            password: 'secretpassword',
+            email: 'user@tuic',
+            enable: true,
+          },
+        ],
+      },
+      streamSettings: {
+        externalProxy: [
+          {
+            dest: 'host-us.example.com',
+            port: 9443,
+            remark: 'US',
+            sni: 'override.example.com',
+            alpn: ['h3', 'h2'],
+            allowInsecure: true,
+          },
+        ],
+      },
+    });
+
+    const entries = genAllLinks({
+      inbound,
+      remark: 'TUIC-Node',
+      client: {
+        uuid: '11111111-2222-3333-4444-555555555555',
+        password: 'secretpassword',
+        email: 'user@tuic',
+      },
+      fallbackHostname: 'panel.example.com',
+    });
+
+    expect(entries).toHaveLength(1);
+    expect(entries[0].remark).toBe('TUIC-Node-US');
+
+    const link = entries[0].link;
+    expect(link).toContain(
+      'tuic://11111111-2222-3333-4444-555555555555:[email protected]:9443',
+    );
+    expect(link).toContain('sni=override.example.com');
+    expect(link).toContain('alpn=h3%2Ch2');
+    expect(link).toContain('allow_insecure=1');
+    expect(link).toContain('#TUIC-Node-US');
+    expect(link).not.toContain('#TUIC-Node-US-US');
+  });
+});

+ 5 - 0
frontend/src/test/inbound-tag.test.ts

@@ -31,6 +31,11 @@ describe('composeInboundTag transport suffix parity', () => {
       'in-443-udp',
     ],
     ['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'],
+    [
+      'tuic forced udp',
+      base({ protocol: 'tuic', streamSettings: { network: 'tcp' } }),
+      'in-443-udp',
+    ],
     [
       'shadowsocks tcp,udp',
       base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }),

+ 10 - 0
frontend/src/test/link-label.test.ts

@@ -86,4 +86,14 @@ describe('link-label parseLinkParts', () => {
     expect(parts?.port).toBe('36541');
     expect(parts && linkMetaText(parts)).toBe('wg-Майфун:36541');
   });
+
+  it('labels a tuic link with QUIC network and TLS security', () => {
+    const link = 'tuic://uuid:[email protected]:8443?congestion_control=bbr#tuic-remark';
+    const parts = parseLinkParts(link);
+    expect(parts?.protocol).toBe('TUIC');
+    expect(parts?.network).toBe('QUIC');
+    expect(parts?.security).toBe('TLS');
+    expect(parts?.port).toBe('8443');
+    expect(parts?.remark).toBe('tuic-remark');
+  });
 });

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

@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildTuicClientConfig } from '@/pages/clients/tuicConfig';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+const client: ClientRecord = {
+  id: 1,
+  email: '[email protected]',
+  uuid: 'e79b9107-1607-4e6c-a496-d8f99e4f0dc5',
+  password: 'testpassword123',
+  inboundIds: [10],
+};
+
+const inbound: InboundOption = {
+  id: 10,
+  tag: 'in-8443-udp',
+  remark: 'TUIC Main',
+  protocol: 'tuic',
+  port: 8443,
+  tuicServer: {
+    sni: 'vpn.example.com',
+    congestion_control: 'cubic',
+    alpn: ['h3', 'spdy/3.1'],
+    udp_relay_mode: 'native',
+    zero_rtt_handshake: true,
+  },
+};
+
+describe('buildTuicClientConfig', () => {
+  it('builds valid YAML proxy entry from tuicServer option', () => {
+    const cfg = buildTuicClientConfig(client, inbound, 'server.example.com', '');
+    expect(cfg).toContain('type: tuic');
+    expect(cfg).toContain('server: server.example.com');
+    expect(cfg).toContain('port: 8443');
+    expect(cfg).toContain('uuid: e79b9107-1607-4e6c-a496-d8f99e4f0dc5');
+    expect(cfg).toContain('password: "testpassword123"');
+    expect(cfg).toContain('sni: vpn.example.com');
+    expect(cfg).toContain('congestion-controller: cubic');
+    expect(cfg).toContain('udp-relay-mode: native');
+    expect(cfg).toContain('reduce-rtt: true');
+  });
+
+  it('falls back to endpointHost when sni is empty', () => {
+    const inboundNoSni: InboundOption = {
+      ...inbound,
+      tuicServer: {
+        ...inbound.tuicServer,
+        sni: '',
+      },
+    };
+    const cfg = buildTuicClientConfig(client, inboundNoSni, 'server.example.com', '');
+    expect(cfg).toContain('sni: server.example.com');
+  });
+
+  it('escapes quotes in passwords and remarks', () => {
+    const dangerousClient: ClientRecord = {
+      ...client,
+      password: 'pass"with"quotes\nnewline',
+    };
+    const cfg = buildTuicClientConfig(dangerousClient, inbound, 'server.example.com', '');
+    expect(cfg).toContain('password: "pass\\"with\\"quotes\\nnewline"');
+  });
+});

+ 35 - 1
install.sh

@@ -367,6 +367,34 @@ install_acme() {
     return 0
 }
 
+install_tuic_server() {
+    local target_arch=""
+    case "$(arch)" in
+        amd64|x86_64) target_arch="x86_64-unknown-linux-musl" ;;
+        arm64|aarch64) target_arch="aarch64-unknown-linux-musl" ;;
+        armv7|armv7l) target_arch="armv7-unknown-linux-musleabihf" ;;
+        386|i386|i686) target_arch="i686-unknown-linux-musl" ;;
+        armv6|armv6l|armv5|armv5l|s390x)
+            echo -e "${yellow}tuic-server does not provide prebuilt binaries for $(arch); TUIC inbounds will be unavailable on this machine${plain}"
+            return 0
+            ;;
+        *) return 0 ;;
+    esac
+
+    local tuic_url="https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-${target_arch}"
+    echo -e "${green}Installing tuic-server (${target_arch})...${plain}"
+    mkdir -p "${xui_folder}/bin"
+    if curl -fLR --connect-timeout 15 --retry 3 -o "${xui_folder}/bin/tuic-server" "${tuic_url}" && [[ -s "${xui_folder}/bin/tuic-server" ]]; then
+        chmod +x "${xui_folder}/bin/tuic-server"
+        cp -f "${xui_folder}/bin/tuic-server" /usr/local/bin/tuic-server 2>/dev/null || true
+        chmod +x /usr/local/bin/tuic-server 2>/dev/null || true
+        echo -e "${green}tuic-server installed successfully${plain}"
+    else
+        rm -f "${xui_folder}/bin/tuic-server"
+        echo -e "${yellow}Failed to download tuic-server (optional), skipping${plain}"
+    fi
+}
+
 setup_ssl_certificate() {
     local domain="$1"
     local server_ip="$2"
@@ -1570,6 +1598,7 @@ install_x-ui() {
         # an inbound port with an outdated secret, silently breaking new clients.
         # The freshly installed panel respawns a clean mtg per inbound on start.
         pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
+        pkill -f 'tuic-server.*-c .*bin/tuic/tuic_[0-9]+\.json' > /dev/null 2>&1 || true
 
         # bin/ is about to be wiped wholesale by the tar extraction below. The
         # release only ships known assets (xray/mtg binaries, the bundled
@@ -1634,6 +1663,11 @@ install_x-ui() {
     elif [[ -f bin/mtg-linux-$(arch) ]]; then
         chmod +x bin/mtg-linux-$(arch)
     fi
+    if [[ -f bin/tuic-server ]]; then
+        chmod +x bin/tuic-server
+    else
+        install_tuic_server
+    fi
 
     # Restore anything from the old bin/ that the fresh release doesn't ship
     # (custom geoip/geosite files, or anything else an admin hand-placed
@@ -1652,7 +1686,7 @@ install_x-ui() {
         while IFS= read -r -d '' f; do
             local rel="${f#"${custom_bin_backup}"/}"
             case "${rel}" in
-                config.json | mtproto | mtproto/*) continue ;;
+                config.json | mtproto | mtproto/* | tuic | tuic/*) continue ;;
             esac
             if [[ ! -e "bin/${rel}" ]]; then
                 mkdir -p "bin/$(dirname "${rel}")"

+ 2 - 1
internal/database/model/model.go

@@ -33,6 +33,7 @@ const (
 	Hysteria    Protocol = "hysteria"
 	MTProto     Protocol = "mtproto"
 	AmneziaWG   Protocol = "amneziawg"
+	TUIC        Protocol = "tuic"
 )
 
 // User represents a user account in the 3x-ui panel.
@@ -62,7 +63,7 @@ type Inbound struct {
 	// Xray configuration fields
 	Listen            string   `json:"listen" form:"listen"`
 	Port              int      `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
-	Protocol          Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg" example:"vless"`
+	Protocol          Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg tuic" example:"vless"`
 	Settings          string   `json:"settings" form:"settings"`
 	StreamSettings    string   `json:"streamSettings" form:"streamSettings"`
 	Tag               string   `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`

+ 58 - 0
internal/sub/clash_service.go

@@ -13,6 +13,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 
@@ -401,6 +402,9 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 	if inbound.Protocol == model.WireGuard {
 		return s.buildWireguardProxy(subReq, inbound, client, ep)
 	}
+	if inbound.Protocol == model.TUIC {
+		return s.buildTuicProxy(subReq, inbound, client, ep)
+	}
 	if inbound.Protocol == model.AmneziaWG {
 		return s.buildAmneziaWGProxy(subReq, inbound, client, ep)
 	}
@@ -611,6 +615,60 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
 	return proxy
 }
 
+func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
+	inst, ok := tuic.InstanceFromInbound(inbound)
+	if !ok {
+		return nil
+	}
+	uuid := client.ID
+	password := client.Password
+	for _, c := range inst.Clients {
+		if c.Email == client.Email {
+			if uuid == "" {
+				uuid = c.UUID
+			}
+			if password == "" {
+				password = c.Password
+			}
+			break
+		}
+	}
+	if uuid == "" || password == "" {
+		return nil
+	}
+	server := inbound.Listen
+	if server == "" || server == "0.0.0.0" || server == "::" {
+		server = subReq.resolveInboundAddress(inbound)
+	}
+	proxy := map[string]any{
+		"name":                  subReq.endpointRemark(inbound, client.Email, ep, "tuic"),
+		"type":                  "tuic",
+		"server":                server,
+		"port":                  inbound.Port,
+		"uuid":                  uuid,
+		"password":              password,
+		"congestion-controller": inst.CongestionControl,
+		"udp-relay-mode":        inst.UDPRelayMode,
+		"reduce-rtt":            inst.ZeroRTTHandshake,
+	}
+	if len(inst.ALPN) > 0 {
+		proxy["alpn"] = inst.ALPN
+	}
+	if inst.SNI != "" {
+		proxy["sni"] = inst.SNI
+	}
+	if sni, ok := externalProxySNI(ep); ok {
+		proxy["sni"] = sni
+	}
+	if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
+		proxy["alpn"] = strings.Split(alpn, ",")
+	}
+	if ai, ok := ep["allowInsecure"].(bool); ok && ai {
+		proxy["skip-cert-verify"] = true
+	}
+	return proxy
+}
+
 // amneziaWGClientAddresses prefers this inbound's own settings entry over the
 // shared clients.wg_allowed_ips column, which for an identity attached to both
 // a wireguard and an amneziawg inbound holds the other one's address.

+ 1 - 1
internal/sub/json_service.go

@@ -649,7 +649,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 				continue
 			}
 			newOutbounds = append(newOutbounds, wgOutbound)
-		case "amneziawg":
+		case "amneziawg", "tuic":
 			continue
 		}
 

+ 11 - 0
internal/sub/json_service_test.go

@@ -520,3 +520,14 @@ func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
 		t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
 	}
 }
+
+func TestSubJsonServiceSkipsTUIC(t *testing.T) {
+	if got := NewSubJsonService("", "", "", "", nil).getConfig(
+		&SubService{address: "sub.example.com"},
+		&model.Inbound{Listen: "203.0.113.8", Port: 8443, Protocol: model.TUIC},
+		model.Client{},
+		"sub.example.com",
+	); len(got) != 0 {
+		t.Fatalf("getConfig emitted %d unsupported TUIC Xray config(s)", len(got))
+	}
+}

+ 79 - 1
internal/sub/service.go

@@ -22,6 +22,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -583,7 +584,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
 		JOIN clients ON clients.id = client_inbounds.client_id
 		WHERE
-			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto')
+			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic')
 			AND clients.sub_id = ? AND inbounds.enable = ?
 	)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
 	if err != nil {
@@ -736,10 +737,87 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 		return s.genWireguardLink(inbound, email)
 	case "amneziawg":
 		return s.genAmneziaWGLink(inbound, email)
+	case "tuic":
+		return s.genTuicLink(inbound, email)
 	}
 	return ""
 }
 
+func (s *SubService) genTuicLink(inbound *model.Inbound, email string) string {
+	if inbound.Protocol != model.TUIC {
+		return ""
+	}
+	inst, ok := tuic.InstanceFromInbound(inbound)
+	if !ok {
+		return ""
+	}
+	var client *tuic.TuicClientSettings
+	for _, c := range inst.Clients {
+		if c.Email == email {
+			client = &c
+			break
+		}
+	}
+	if client == nil && len(inst.Clients) > 0 && email == "" {
+		client = &inst.Clients[0]
+	}
+	if client == nil || client.UUID == "" || client.Password == "" {
+		return ""
+	}
+
+	params := make(map[string]string)
+	cc := inst.CongestionControl
+	if cc == "" {
+		cc = "bbr"
+	}
+	params["congestion_control"] = cc
+
+	if len(inst.ALPN) > 0 {
+		params["alpn"] = strings.Join(inst.ALPN, ",")
+	}
+	if inst.SNI != "" {
+		params["sni"] = inst.SNI
+	}
+	if inst.UDPRelayMode != "" {
+		params["udp_relay_mode"] = inst.UDPRelayMode
+	}
+	params["allow_insecure"] = "0"
+
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	externalProxies, _ := stream["externalProxy"].([]any)
+	if len(externalProxies) > 0 {
+		links := make([]string, 0, len(externalProxies))
+		for _, externalProxy := range externalProxies {
+			ep, ok := externalProxy.(map[string]any)
+			if !ok {
+				continue
+			}
+			dest, _ := ep["dest"].(string)
+			portF, okPort := ep["port"].(float64)
+			if dest == "" || !okPort {
+				continue
+			}
+			epParams := cloneStringMap(params)
+			if sni, ok := externalProxySNI(ep); ok {
+				epParams["sni"] = sni
+			}
+			if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
+				epParams["alpn"] = alpn
+			}
+			if ai, ok := ep["allowInsecure"].(bool); ok && ai {
+				epParams["allow_insecure"] = "1"
+			}
+			link := fmt.Sprintf("tuic://%s:%s@%s", encodeUserinfo(client.UUID), encodeUserinfo(client.Password), joinHostPort(dest, int(portF)))
+			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "")))
+		}
+		return strings.Join(links, "\n")
+	}
+
+	host := s.resolveInboundAddress(inbound)
+	link := fmt.Sprintf("tuic://%s:%s@%s", encodeUserinfo(client.UUID), encodeUserinfo(client.Password), joinHostPort(host, inbound.Port))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
+}
+
 // genWireguardLink builds a per-client wireguard:// share link mirroring the
 // frontend genWireguardLink: the client's private key is the userinfo, the
 // server public key (derived from the inbound secretKey) and the client's

+ 190 - 0
internal/sub/service_tuic_test.go

@@ -0,0 +1,190 @@
+package sub
+
+import (
+	"net/url"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestGenTuicLinkBasic(t *testing.T) {
+	inbound := &model.Inbound{
+		Listen:   "198.51.100.1",
+		Port:     8443,
+		Protocol: model.TUIC,
+		Remark:   "tuic-test",
+		Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key"},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
+	}
+
+	s := &SubService{}
+	link := s.genTuicLink(inbound, "user@test")
+
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("link does not parse: %v\ngot: %s", err, link)
+	}
+	if u.Scheme != "tuic" {
+		t.Fatalf("scheme = %q, want tuic", u.Scheme)
+	}
+	if u.Host != "198.51.100.1:8443" {
+		t.Fatalf("host = %q, want 198.51.100.1:8443", u.Host)
+	}
+	if u.User.Username() != "11111111-1111-1111-1111-111111111111" {
+		t.Fatalf("username = %q, want uuid", u.User.Username())
+	}
+	pass, ok := u.User.Password()
+	if !ok || pass != "testpassword" {
+		t.Fatalf("password = %q, want testpassword", pass)
+	}
+	q := u.Query()
+	if q.Get("congestion_control") != "bbr" {
+		t.Fatalf("congestion_control = %q, want bbr", q.Get("congestion_control"))
+	}
+	if q.Get("allow_insecure") != "0" {
+		t.Fatalf("allow_insecure = %q, want 0", q.Get("allow_insecure"))
+	}
+	if q.Get("alpn") != "h3,spdy/3.1" {
+		t.Fatalf("alpn = %q, want h3,spdy/3.1", q.Get("alpn"))
+	}
+	if q.Get("udp_relay_mode") != "native" {
+		t.Fatalf("udp_relay_mode = %q, want native", q.Get("udp_relay_mode"))
+	}
+	if u.Fragment != "tuic-test-user@test" {
+		t.Fatalf("fragment = %q, want tuic-test-user@test", u.Fragment)
+	}
+}
+
+func TestGenTuicLinkExplicitParams(t *testing.T) {
+	inbound := &model.Inbound{
+		Listen:   "198.51.100.1",
+		Port:     8443,
+		Protocol: model.TUIC,
+		Remark:   "tuic-test",
+		Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key","congestion_control":"cubic","alpn":["h3"],"sni":"tuic.example.com","udp_relay_mode":"quic"},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
+	}
+
+	s := &SubService{}
+	link := s.genTuicLink(inbound, "user@test")
+
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("link does not parse: %v\ngot: %s", err, link)
+	}
+	q := u.Query()
+	if q.Get("congestion_control") != "cubic" {
+		t.Fatalf("congestion_control = %q, want cubic", q.Get("congestion_control"))
+	}
+	if q.Get("alpn") != "h3" {
+		t.Fatalf("alpn = %q, want h3", q.Get("alpn"))
+	}
+	if q.Get("sni") != "tuic.example.com" {
+		t.Fatalf("sni = %q, want tuic.example.com", q.Get("sni"))
+	}
+	if q.Get("udp_relay_mode") != "quic" {
+		t.Fatalf("udp_relay_mode = %q, want quic", q.Get("udp_relay_mode"))
+	}
+}
+
+func TestGenTuicLinkExternalProxyFanOut(t *testing.T) {
+	inbound := &model.Inbound{
+		Listen:         "0.0.0.0",
+		Port:           8443,
+		Protocol:       model.TUIC,
+		Remark:         "tuic-base",
+		Settings:       `{"server":{"certificate":"/path/cert","private_key":"/path/key"},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
+		StreamSettings: `{"externalProxy":[{"dest":"host1.example.com","port":9443,"remark":"US"},{"dest":"host2.example.com","port":10443,"remark":"EU","allowInsecure":true}]}`,
+	}
+
+	s := &SubService{}
+	links := s.genTuicLink(inbound, "user@test")
+	lines := strings.Split(strings.TrimSpace(links), "\n")
+	if len(lines) != 2 {
+		t.Fatalf("expected 2 links for externalProxy fan-out, got %d:\n%s", len(lines), links)
+	}
+
+	u1, err := url.Parse(lines[0])
+	if err != nil {
+		t.Fatalf("first link does not parse: %v", err)
+	}
+	if u1.Host != "host1.example.com:9443" {
+		t.Fatalf("first host = %q, want host1.example.com:9443", u1.Host)
+	}
+	if !strings.Contains(u1.Fragment, "US") {
+		t.Fatalf("first fragment = %q, want to contain US", u1.Fragment)
+	}
+	if u1.Query().Get("allow_insecure") != "0" {
+		t.Fatalf("first allow_insecure = %q, want 0", u1.Query().Get("allow_insecure"))
+	}
+	if u1.Query().Has("allowInsecure") {
+		t.Fatalf("first link should not have allowInsecure")
+	}
+
+	u2, err := url.Parse(lines[1])
+	if err != nil {
+		t.Fatalf("second link does not parse: %v", err)
+	}
+	if u2.Host != "host2.example.com:10443" {
+		t.Fatalf("second host = %q, want host2.example.com:10443", u2.Host)
+	}
+	if !strings.Contains(u2.Fragment, "EU") {
+		t.Fatalf("second fragment = %q, want to contain EU", u2.Fragment)
+	}
+	if u2.Query().Get("allow_insecure") != "1" {
+		t.Fatalf("second allow_insecure = %q, want 1", u2.Query().Get("allow_insecure"))
+	}
+	if u2.Query().Has("allowInsecure") {
+		t.Fatalf("second link should not have allowInsecure")
+	}
+}
+
+func TestBuildTuicProxy_ExternalProxyOverrides(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{address: "sub.example.com"}}
+	inbound := &model.Inbound{
+		Listen:   "198.51.100.1",
+		Port:     8443,
+		Protocol: model.TUIC,
+		Remark:   "tuic-base",
+		Settings: `{"server":{"certificate":"/path/cert","private_key":"/path/key","sni":"base.example.com","alpn":["h3"]},"clients":[{"uuid":"11111111-1111-1111-1111-111111111111","password":"testpassword","email":"user@test"}]}`,
+	}
+	client := model.Client{Email: "user@test"}
+
+	// 1. Without overrides from ep
+	baseProxy := svc.buildTuicProxy(svc.SubService, inbound, client, nil)
+	if baseProxy == nil {
+		t.Fatal("baseProxy is nil")
+	}
+	if baseProxy["sni"] != "base.example.com" {
+		t.Fatalf("base sni = %v, want base.example.com", baseProxy["sni"])
+	}
+	if !reflect.DeepEqual(baseProxy["alpn"], []string{"h3"}) {
+		t.Fatalf("base alpn = %v, want [h3]", baseProxy["alpn"])
+	}
+	if _, ok := baseProxy["skip-cert-verify"]; ok {
+		t.Fatalf("base skip-cert-verify should not be set")
+	}
+
+	// 2. With overrides from ep
+	ep := map[string]any{
+		"dest":          "custom.example.com",
+		"port":          float64(9443),
+		"remark":        "custom-node",
+		"sni":           "custom.sni.com",
+		"alpn":          "h3,spdy/3.1",
+		"allowInsecure": true,
+	}
+	proxy := svc.buildTuicProxy(svc.SubService, inbound, client, ep)
+	if proxy == nil {
+		t.Fatal("proxy is nil")
+	}
+	if proxy["sni"] != "custom.sni.com" {
+		t.Fatalf("sni = %v, want custom.sni.com", proxy["sni"])
+	}
+	if !reflect.DeepEqual(proxy["alpn"], []string{"h3", "spdy/3.1"}) {
+		t.Fatalf("alpn = %v, want [h3 spdy/3.1]", proxy["alpn"])
+	}
+	if proxy["skip-cert-verify"] != true {
+		t.Fatalf("skip-cert-verify = %v, want true", proxy["skip-cert-verify"])
+	}
+}

+ 94 - 0
internal/tuic/config.go

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

+ 87 - 0
internal/tuic/config_test.go

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

+ 231 - 0
internal/tuic/manager.go

@@ -0,0 +1,231 @@
+package tuic
+
+import (
+	"fmt"
+	"net"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+type managed struct {
+	proc         *Process
+	relay        *udpRelay
+	tag          string
+	configPath   string
+	structuralFP string
+	usersFP      string
+}
+
+type Manager struct {
+	mu           sync.Mutex
+	procs        map[int]*managed
+	lastStartErr map[int]string
+}
+
+var (
+	managerInstance *Manager
+	managerOnce     sync.Once
+)
+
+func GetManager() *Manager {
+	managerOnce.Do(func() {
+		managerInstance = &Manager{
+			procs:        make(map[int]*managed),
+			lastStartErr: make(map[int]string),
+		}
+		if n := killStrayTuicProcesses(GetBinaryPath()); n > 0 {
+			logger.Warningf("tuic: terminated %d orphaned tuic-server process(es) from a previous run", n)
+		}
+	})
+	return managerInstance
+}
+
+func (m *Manager) HasRunning() bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, mg := range m.procs {
+		if mg.proc != nil && mg.proc.IsRunning() {
+			return true
+		}
+	}
+	return false
+}
+
+func (m *Manager) Ensure(inst Instance) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.ensureLocked(inst)
+}
+
+func (m *Manager) ensureLocked(inst Instance) error {
+	if len(inst.Clients) == 0 {
+		m.removeLocked(inst.Id)
+		return nil
+	}
+
+	structuralFP := inst.StructuralFingerprint()
+	usersFP := inst.UsersFingerprint()
+
+	uuidToEmail := make(map[string]string, len(inst.Clients))
+	for _, c := range inst.Clients {
+		if c.UUID != "" && c.Email != "" {
+			uuidToEmail[c.UUID] = c.Email
+		}
+	}
+
+	if existing, ok := m.procs[inst.Id]; ok && existing != nil {
+		if existing.proc != nil && existing.proc.IsRunning() &&
+			existing.structuralFP == structuralFP && existing.usersFP == usersFP {
+			existing.tag = inst.Tag
+			existing.proc.UpdateClients(uuidToEmail)
+			return nil
+		}
+		stopManaged(existing)
+		delete(m.procs, inst.Id)
+	}
+
+	proc, relay, configPath, err := m.startLocked(inst, uuidToEmail)
+	if err != nil {
+		if m.lastStartErr[inst.Id] != err.Error() {
+			m.lastStartErr[inst.Id] = err.Error()
+			logger.Warningf("tuic: failed to start tuic-server for inbound %d (%s): %v", inst.Id, inst.Tag, err)
+		}
+		return err
+	}
+	delete(m.lastStartErr, inst.Id)
+
+	m.procs[inst.Id] = &managed{
+		proc:         proc,
+		relay:        relay,
+		tag:          inst.Tag,
+		configPath:   configPath,
+		structuralFP: structuralFP,
+		usersFP:      usersFP,
+	}
+	return nil
+}
+
+func (m *Manager) startLocked(inst Instance, uuidToEmail map[string]string) (*Process, *udpRelay, string, error) {
+	port, err := freeLoopbackUDPPort()
+	if err != nil {
+		return nil, nil, "", fmt.Errorf("tuic: pick sidecar port for %d: %w", inst.Id, err)
+	}
+	upstream := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
+	configBytes, err := GenerateConfig(inst, net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+	if err != nil {
+		return nil, nil, "", fmt.Errorf("tuic: generate config for %d: %w", inst.Id, err)
+	}
+	configPath, err := WriteConfigFile(inst.Id, configBytes)
+	if err != nil {
+		return nil, nil, "", fmt.Errorf("tuic: write config for %d: %w", inst.Id, err)
+	}
+	relay, err := startUDPRelay(inst.BindTo(), upstream, relayFlowIdle)
+	if err != nil {
+		_ = RemoveConfigFile(inst.Id)
+		return nil, nil, "", fmt.Errorf("tuic: listen on %s for %d: %w", inst.BindTo(), inst.Id, err)
+	}
+	proc := newProcess(configPath, inst.Tag, uuidToEmail)
+	if err := proc.Start(); err != nil {
+		relay.Close()
+		_ = RemoveConfigFile(inst.Id)
+		return nil, nil, "", err
+	}
+	return proc, relay, configPath, nil
+}
+
+func stopManaged(mg *managed) {
+	if mg.proc != nil && mg.proc.IsRunning() {
+		_ = mg.proc.Stop()
+	}
+	mg.relay.Close()
+}
+
+func (m *Manager) GetActiveClients(window time.Duration) ([]string, []string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	var emails []string
+	var tags []string
+	for _, mg := range m.procs {
+		if mg.proc != nil && mg.proc.IsRunning() {
+			active := mg.proc.GetActiveEmails(window)
+			if len(active) > 0 {
+				emails = append(emails, active...)
+				tags = append(tags, mg.tag)
+			}
+		}
+	}
+	return emails, tags
+}
+
+type InboundTrafficDelta struct {
+	Tag  string
+	Up   int64
+	Down int64
+}
+
+func (m *Manager) CollectTraffic() []InboundTrafficDelta {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	var out []InboundTrafficDelta
+	for _, mg := range m.procs {
+		if mg.relay != nil && mg.proc != nil && mg.proc.IsRunning() {
+			deltaUp, deltaDown := mg.relay.CollectTraffic()
+			if deltaUp > 0 || deltaDown > 0 {
+				out = append(out, InboundTrafficDelta{
+					Tag:  mg.tag,
+					Up:   deltaUp,
+					Down: deltaDown,
+				})
+			}
+		}
+	}
+	return out
+}
+
+func (m *Manager) Remove(id int) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.removeLocked(id)
+}
+
+func (m *Manager) removeLocked(id int) {
+	if existing, ok := m.procs[id]; ok && existing != nil {
+		stopManaged(existing)
+		_ = RemoveConfigFile(id)
+		delete(m.procs, id)
+		delete(m.lastStartErr, id)
+	}
+}
+
+func (m *Manager) Reconcile(desired []Instance) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	desiredMap := make(map[int]Instance, len(desired))
+	for _, inst := range desired {
+		desiredMap[inst.Id] = inst
+	}
+
+	for id := range m.procs {
+		if _, ok := desiredMap[id]; !ok {
+			m.removeLocked(id)
+		}
+	}
+
+	for _, inst := range desired {
+		_ = m.ensureLocked(inst)
+	}
+}
+
+func (m *Manager) StopAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for id, mg := range m.procs {
+		stopManaged(mg)
+		_ = RemoveConfigFile(id)
+	}
+	m.procs = make(map[int]*managed)
+}

+ 95 - 0
internal/tuic/manager_test.go

@@ -0,0 +1,95 @@
+package tuic
+
+import (
+	"encoding/json"
+	"net"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+)
+
+func TestEnsureFrontsSidecarWithRelayAndRemoveReleasesPort(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("uses a shell script as the sidecar binary")
+	}
+	bin := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", bin)
+	if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	port, err := freeLoopbackUDPPort()
+	if err != nil {
+		t.Fatal(err)
+	}
+	inst := Instance{
+		Id: 7, Tag: "tuic-7", Listen: "127.0.0.1", Port: port,
+		Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
+	}
+	m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
+	t.Cleanup(m.StopAll)
+
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure: %v", err)
+	}
+	if c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}); err == nil {
+		_ = c.Close()
+		t.Fatal("the relay must own the inbound's public port while the sidecar runs")
+	}
+	raw, err := os.ReadFile(ConfigPathForID(7))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var cfg struct {
+		Server string `json:"server"`
+	}
+	if err := json.Unmarshal(raw, &cfg); err != nil {
+		t.Fatal(err)
+	}
+	host, sidecarPort, err := net.SplitHostPort(cfg.Server)
+	if err != nil || host != "127.0.0.1" || sidecarPort == "" || cfg.Server == inst.BindTo() {
+		t.Fatalf("sidecar bound to %q, want a loopback port other than the public %q", cfg.Server, inst.BindTo())
+	}
+
+	m.Remove(7)
+	c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
+	if err != nil {
+		t.Fatalf("public port still held after Remove: %v", err)
+	}
+	_ = c.Close()
+}
+
+func TestEnsureUpdatesTagWithoutRestart(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("uses a shell script as the sidecar binary")
+	}
+	bin := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", bin)
+	if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	port, err := freeLoopbackUDPPort()
+	if err != nil {
+		t.Fatal(err)
+	}
+	inst := Instance{
+		Id: 8, Tag: "old-tag", Listen: "127.0.0.1", Port: port,
+		Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
+	}
+	m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
+	t.Cleanup(m.StopAll)
+
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure: %v", err)
+	}
+	inst.Tag = "new-tag"
+	if err := m.Ensure(inst); err != nil {
+		t.Fatalf("Ensure updated tag: %v", err)
+	}
+	m.mu.Lock()
+	gotTag := m.procs[8].tag
+	m.mu.Unlock()
+	if gotTag != "new-tag" {
+		t.Fatalf("manager tag = %q, want %q", gotTag, "new-tag")
+	}
+}

+ 87 - 0
internal/tuic/orphans_linux.go

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

+ 5 - 0
internal/tuic/orphans_other.go

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

+ 292 - 0
internal/tuic/process.go

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

+ 7 - 0
internal/tuic/process_other.go

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

+ 67 - 0
internal/tuic/process_windows.go

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

+ 196 - 0
internal/tuic/relay.go

@@ -0,0 +1,196 @@
+package tuic
+
+import (
+	"errors"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+// A QUIC flow the sidecar has not touched for this long is forgotten; QUIC's
+// own max_idle_time (15s by default) closes the session well before that.
+const relayFlowIdle = 2 * time.Minute
+
+const (
+	relaySocketBuffer = 4 << 20
+	maxRelayFlows     = 4096
+)
+
+// udpRelay owns an inbound's public UDP port and forwards each client's
+// datagrams to the sidecar on loopback, which is the only place the panel can
+// count the inbound's bytes: upstream tuic-server exposes no stats API and
+// its socket syscalls never reach /proc/<pid>/io. Per-client attribution stays
+// impossible because QUIC payloads are opaque.
+type udpRelay struct {
+	public    *net.UDPConn
+	upstream  *net.UDPAddr
+	idle      time.Duration
+	up        atomic.Int64
+	down      atomic.Int64
+	mu        sync.Mutex
+	flows     map[string]*relayFlow
+	done      chan struct{}
+	closeOnce sync.Once
+	wg        sync.WaitGroup
+}
+
+type relayFlow struct {
+	conn     *net.UDPConn
+	client   *net.UDPAddr
+	lastSeen atomic.Int64
+}
+
+func startUDPRelay(bind string, upstream *net.UDPAddr, idle time.Duration) (*udpRelay, error) {
+	addr, err := net.ResolveUDPAddr("udp", bind)
+	if err != nil {
+		return nil, err
+	}
+	public, err := net.ListenUDP("udp", addr)
+	if err != nil {
+		return nil, err
+	}
+	_ = public.SetReadBuffer(relaySocketBuffer)
+	_ = public.SetWriteBuffer(relaySocketBuffer)
+	r := &udpRelay{
+		public:   public,
+		upstream: upstream,
+		idle:     idle,
+		flows:    make(map[string]*relayFlow),
+		done:     make(chan struct{}),
+	}
+	r.wg.Add(2)
+	go r.serve()
+	go r.sweep()
+	return r, nil
+}
+
+func freeLoopbackUDPPort() (int, error) {
+	c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+	if err != nil {
+		return 0, err
+	}
+	defer c.Close()
+	return c.LocalAddr().(*net.UDPAddr).Port, nil
+}
+
+func (r *udpRelay) LocalAddr() net.Addr {
+	return r.public.LocalAddr()
+}
+
+// CollectTraffic returns the client-to-sidecar and sidecar-to-client bytes
+// relayed since the previous call.
+func (r *udpRelay) CollectTraffic() (up, down int64) {
+	return r.up.Swap(0), r.down.Swap(0)
+}
+
+func (r *udpRelay) Close() {
+	if r == nil {
+		return
+	}
+	r.closeOnce.Do(func() {
+		close(r.done)
+		_ = r.public.Close()
+		r.mu.Lock()
+		for key, f := range r.flows {
+			_ = f.conn.Close()
+			delete(r.flows, key)
+		}
+		r.mu.Unlock()
+		r.wg.Wait()
+	})
+}
+
+func (r *udpRelay) serve() {
+	defer r.wg.Done()
+	buf := make([]byte, 65535)
+	for {
+		n, client, err := r.public.ReadFromUDP(buf)
+		if err != nil {
+			if errors.Is(err, net.ErrClosed) {
+				return
+			}
+			continue
+		}
+		flow, err := r.flowFor(client)
+		if err != nil {
+			continue
+		}
+		if _, err := flow.conn.Write(buf[:n]); err == nil {
+			r.up.Add(int64(n))
+		}
+	}
+}
+
+func (r *udpRelay) flowFor(client *net.UDPAddr) (*relayFlow, error) {
+	key := client.String()
+	now := time.Now().UnixMilli()
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	select {
+	case <-r.done:
+		return nil, net.ErrClosed
+	default:
+	}
+	if f, ok := r.flows[key]; ok {
+		f.lastSeen.Store(now)
+		return f, nil
+	}
+	if len(r.flows) >= maxRelayFlows {
+		return nil, errors.New("tuic: max relay flows reached")
+	}
+	conn, err := net.DialUDP("udp", nil, r.upstream)
+	if err != nil {
+		return nil, err
+	}
+	_ = conn.SetReadBuffer(relaySocketBuffer)
+	_ = conn.SetWriteBuffer(relaySocketBuffer)
+	f := &relayFlow{conn: conn, client: client}
+	f.lastSeen.Store(now)
+	r.flows[key] = f
+	r.wg.Add(1)
+	go r.pump(f)
+	return f, nil
+}
+
+func (r *udpRelay) pump(f *relayFlow) {
+	defer r.wg.Done()
+	buf := make([]byte, 65535)
+	for {
+		n, err := f.conn.Read(buf)
+		if err != nil {
+			if errors.Is(err, net.ErrClosed) {
+				return
+			}
+			// ICMP unreachable while the sidecar restarts: drop it, keep the flow.
+			time.Sleep(20 * time.Millisecond)
+			continue
+		}
+		if _, err := r.public.WriteToUDP(buf[:n], f.client); err == nil {
+			r.down.Add(int64(n))
+		}
+		f.lastSeen.Store(time.Now().UnixMilli())
+	}
+}
+
+func (r *udpRelay) sweep() {
+	defer r.wg.Done()
+	ticker := time.NewTicker(r.idle / 2)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-r.done:
+			return
+		case <-ticker.C:
+			cutoff := time.Now().Add(-r.idle).UnixMilli()
+			r.mu.Lock()
+			for key, f := range r.flows {
+				if f.lastSeen.Load() < cutoff {
+					_ = f.conn.Close()
+					delete(r.flows, key)
+				}
+			}
+			r.mu.Unlock()
+		}
+	}
+}

+ 127 - 0
internal/tuic/relay_test.go

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

+ 249 - 0
internal/tuic/types.go

@@ -0,0 +1,249 @@
+package tuic
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"slices"
+	"strconv"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+type TuicServerSettings struct {
+	Certificate           string   `json:"certificate"`
+	PrivateKey            string   `json:"private_key"`
+	CongestionControl     string   `json:"congestion_control"`
+	ALPN                  []string `json:"alpn"`
+	UDPRelayMode          string   `json:"udp_relay_mode"`
+	ZeroRTTHandshake      bool     `json:"zero_rtt_handshake"`
+	LogLevel              string   `json:"log_level"`
+	MaxIdleTime           int      `json:"max_idle_time"`
+	AuthenticationTimeout int      `json:"authentication_timeout"`
+	MaxUdpRelayPacketSize int      `json:"max_udp_relay_packet_size"`
+	SNI                   string   `json:"sni,omitempty"`
+}
+
+type TuicClientSettings struct {
+	UUID     string `json:"uuid"`
+	Password string `json:"password"`
+	Email    string `json:"email"`
+}
+
+type Instance struct {
+	Id                    int
+	Tag                   string
+	Listen                string
+	Port                  int
+	Certificate           string
+	PrivateKey            string
+	CongestionControl     string
+	ALPN                  []string
+	UDPRelayMode          string
+	ZeroRTTHandshake      bool
+	LogLevel              string
+	MaxIdleTime           int
+	AuthenticationTimeout int
+	MaxUdpRelayPacketSize int
+	SNI                   string
+	Clients               []TuicClientSettings
+}
+
+func (inst Instance) BindTo() string {
+	listen := inst.Listen
+	if listen == "" {
+		listen = "0.0.0.0"
+	}
+	return net.JoinHostPort(listen, strconv.Itoa(inst.Port))
+}
+
+func (inst Instance) StructuralFingerprint() string {
+	parts := []string{
+		inst.BindTo(),
+		inst.Certificate,
+		inst.PrivateKey,
+		inst.CongestionControl,
+		strings.Join(inst.ALPN, ","),
+		inst.UDPRelayMode,
+		strconv.FormatBool(inst.ZeroRTTHandshake),
+		inst.LogLevel,
+		strconv.Itoa(inst.MaxIdleTime),
+		strconv.Itoa(inst.AuthenticationTimeout),
+		strconv.Itoa(inst.MaxUdpRelayPacketSize),
+		inst.SNI,
+	}
+	return strings.Join(parts, "|")
+}
+
+func (inst Instance) UsersFingerprint() string {
+	pairs := make([]string, 0, len(inst.Clients))
+	for _, c := range inst.Clients {
+		pairs = append(pairs, fmt.Sprintf("%s=%s:%s", c.Email, c.UUID, c.Password))
+	}
+	slices.Sort(pairs)
+	return strings.Join(pairs, "|")
+}
+
+func (inst Instance) FullFingerprint() string {
+	return inst.StructuralFingerprint() + "#" + inst.UsersFingerprint()
+}
+
+func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
+	if ib == nil || ib.Protocol != model.TUIC {
+		return Instance{}, false
+	}
+
+	var parsed struct {
+		Certificate           string   `json:"certificate"`
+		PrivateKey            string   `json:"private_key"`
+		CongestionControl     string   `json:"congestion_control"`
+		ALPN                  []string `json:"alpn"`
+		UDPRelayMode          string   `json:"udp_relay_mode"`
+		ZeroRTTHandshake      *bool    `json:"zero_rtt_handshake"`
+		LogLevel              string   `json:"log_level"`
+		MaxIdleTime           int      `json:"max_idle_time"`
+		AuthenticationTimeout int      `json:"authentication_timeout"`
+		MaxUdpRelayPacketSize int      `json:"max_udp_relay_packet_size"`
+		SNI                   string   `json:"sni"`
+		Server                *struct {
+			Certificate           string   `json:"certificate"`
+			PrivateKey            string   `json:"private_key"`
+			CongestionControl     string   `json:"congestion_control"`
+			ALPN                  []string `json:"alpn"`
+			UDPRelayMode          string   `json:"udp_relay_mode"`
+			ZeroRTTHandshake      *bool    `json:"zero_rtt_handshake"`
+			LogLevel              string   `json:"log_level"`
+			MaxIdleTime           int      `json:"max_idle_time"`
+			AuthenticationTimeout int      `json:"authentication_timeout"`
+			MaxUdpRelayPacketSize int      `json:"max_udp_relay_packet_size"`
+			SNI                   string   `json:"sni"`
+		} `json:"server"`
+		Clients []struct {
+			UUID       string `json:"uuid"`
+			ID         string `json:"id"`
+			Password   string `json:"password"`
+			Email      string `json:"email"`
+			Enable     *bool  `json:"enable"`
+			TotalGB    int64  `json:"totalGB"`
+			ExpiryTime int64  `json:"expiryTime"`
+		} `json:"clients"`
+	}
+
+	if ib.Settings != "" {
+		if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
+			return Instance{}, false
+		}
+	}
+
+	cert := parsed.Certificate
+	key := parsed.PrivateKey
+	cc := parsed.CongestionControl
+	alpn := parsed.ALPN
+	udpRelayMode := parsed.UDPRelayMode
+	zeroRtt := true
+	if parsed.ZeroRTTHandshake != nil {
+		zeroRtt = *parsed.ZeroRTTHandshake
+	}
+	logLevel := parsed.LogLevel
+	maxIdle := parsed.MaxIdleTime
+	authTimeout := parsed.AuthenticationTimeout
+	maxPacketSize := parsed.MaxUdpRelayPacketSize
+	sni := parsed.SNI
+
+	if parsed.Server != nil {
+		if parsed.Server.Certificate != "" {
+			cert = parsed.Server.Certificate
+		}
+		if parsed.Server.PrivateKey != "" {
+			key = parsed.Server.PrivateKey
+		}
+		if parsed.Server.CongestionControl != "" {
+			cc = parsed.Server.CongestionControl
+		}
+		if len(parsed.Server.ALPN) > 0 {
+			alpn = parsed.Server.ALPN
+		}
+		if parsed.Server.UDPRelayMode != "" {
+			udpRelayMode = parsed.Server.UDPRelayMode
+		}
+		if parsed.Server.ZeroRTTHandshake != nil {
+			zeroRtt = *parsed.Server.ZeroRTTHandshake
+		}
+		if parsed.Server.LogLevel != "" {
+			logLevel = parsed.Server.LogLevel
+		}
+		if parsed.Server.MaxIdleTime > 0 {
+			maxIdle = parsed.Server.MaxIdleTime
+		}
+		if parsed.Server.AuthenticationTimeout > 0 {
+			authTimeout = parsed.Server.AuthenticationTimeout
+		}
+		if parsed.Server.MaxUdpRelayPacketSize > 0 {
+			maxPacketSize = parsed.Server.MaxUdpRelayPacketSize
+		}
+		if parsed.Server.SNI != "" {
+			sni = parsed.Server.SNI
+		}
+	}
+
+	if cc == "" {
+		cc = "bbr"
+	}
+	if len(alpn) == 0 {
+		alpn = []string{"h3", "spdy/3.1"}
+	}
+	if udpRelayMode == "" {
+		udpRelayMode = "native"
+	}
+	if logLevel == "" {
+		logLevel = "info"
+	}
+	if maxIdle <= 0 {
+		maxIdle = 15
+	}
+	if authTimeout <= 0 {
+		authTimeout = 3
+	}
+	if maxPacketSize <= 0 {
+		maxPacketSize = 1500
+	}
+
+	clients := make([]TuicClientSettings, 0, len(parsed.Clients))
+	for _, c := range parsed.Clients {
+		if c.Enable != nil && !*c.Enable {
+			continue
+		}
+		uuidVal := strings.ToLower(strings.TrimSpace(c.UUID))
+		if uuidVal == "" {
+			uuidVal = strings.ToLower(strings.TrimSpace(c.ID))
+		}
+		if uuidVal == "" || c.Password == "" {
+			continue
+		}
+		clients = append(clients, TuicClientSettings{
+			UUID:     uuidVal,
+			Password: c.Password,
+			Email:    c.Email,
+		})
+	}
+
+	return Instance{
+		Id:                    ib.Id,
+		Tag:                   ib.Tag,
+		Listen:                ib.Listen,
+		Port:                  ib.Port,
+		Certificate:           cert,
+		PrivateKey:            key,
+		CongestionControl:     cc,
+		ALPN:                  alpn,
+		UDPRelayMode:          udpRelayMode,
+		ZeroRTTHandshake:      zeroRtt,
+		LogLevel:              logLevel,
+		MaxIdleTime:           maxIdle,
+		AuthenticationTimeout: authTimeout,
+		MaxUdpRelayPacketSize: maxPacketSize,
+		SNI:                   sni,
+		Clients:               clients,
+	}, true
+}

+ 124 - 0
internal/tuic/types_test.go

@@ -0,0 +1,124 @@
+package tuic
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestInstanceFromInbound(t *testing.T) {
+	t.Run("valid settings", func(t *testing.T) {
+		ib := &model.Inbound{
+			Id:       10,
+			Tag:      "tuic-in-1",
+			Port:     8443,
+			Listen:   "0.0.0.0",
+			Protocol: model.TUIC,
+			Settings: `{"certificate":"/etc/cert.pem","private_key":"/etc/key.pem","congestion_control":"bbr","alpn":["h3"],"clients":[{"uuid":"11111111-2222-3333-4444-555555555555","password":"pass1","email":"user1@test","enable":true}]}`,
+		}
+		inst, ok := InstanceFromInbound(ib)
+		if !ok {
+			t.Fatal("expected ok to be true")
+		}
+		if inst.Id != 10 || inst.Port != 8443 || inst.Tag != "tuic-in-1" {
+			t.Fatalf("unexpected inst header fields: %+v", inst)
+		}
+		if inst.Certificate != "/etc/cert.pem" || inst.PrivateKey != "/etc/key.pem" {
+			t.Fatalf("unexpected cert/key: %s / %s", inst.Certificate, inst.PrivateKey)
+		}
+		if len(inst.Clients) != 1 {
+			t.Fatalf("expected 1 client, got %d", len(inst.Clients))
+		}
+		if inst.Clients[0].UUID != "11111111-2222-3333-4444-555555555555" || inst.Clients[0].Password != "pass1" {
+			t.Fatalf("unexpected client: %+v", inst.Clients[0])
+		}
+	})
+
+	t.Run("normalizes uuid to lowercase and trims space", func(t *testing.T) {
+		ib := &model.Inbound{
+			Id:       12,
+			Protocol: model.TUIC,
+			Settings: `{"clients":[{"uuid":"  A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D  ","password":"p"}]}`,
+		}
+		inst, ok := InstanceFromInbound(ib)
+		if !ok || len(inst.Clients) != 1 {
+			t.Fatal("expected ok and 1 client")
+		}
+		if inst.Clients[0].UUID != "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d" {
+			t.Fatalf("expected lowercase trimmed UUID, got %q", inst.Clients[0].UUID)
+		}
+	})
+
+	t.Run("nil or wrong protocol", func(t *testing.T) {
+		if _, ok := InstanceFromInbound(nil); ok {
+			t.Fatal("expected false for nil")
+		}
+		if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
+			t.Fatal("expected false for vless")
+		}
+	})
+
+	t.Run("no enabled clients", func(t *testing.T) {
+		ib := &model.Inbound{
+			Id:       11,
+			Protocol: model.TUIC,
+			Settings: `{"clients":[{"uuid":"1111","password":"p","enable":false}]}`,
+		}
+		inst, ok := InstanceFromInbound(ib)
+		if !ok {
+			t.Fatal("expected ok for inbound")
+		}
+		if len(inst.Clients) != 0 {
+			t.Fatalf("expected 0 enabled clients, got %d", len(inst.Clients))
+		}
+	})
+}
+
+func TestFingerprints(t *testing.T) {
+	inst1 := Instance{
+		Id:                1,
+		Port:              8443,
+		Certificate:       "/path/cert",
+		PrivateKey:        "/path/key",
+		CongestionControl: "bbr",
+		Clients: []TuicClientSettings{
+			{UUID: "u1", Password: "p1", Email: "e1"},
+			{UUID: "u2", Password: "p2", Email: "e2"},
+		},
+	}
+	inst2 := Instance{
+		Id:                1,
+		Port:              8443,
+		Certificate:       "/path/cert",
+		PrivateKey:        "/path/key",
+		CongestionControl: "bbr",
+		Clients: []TuicClientSettings{
+			{UUID: "u2", Password: "p2", Email: "e2"},
+			{UUID: "u1", Password: "p1", Email: "e1"},
+		},
+	}
+
+	if inst1.UsersFingerprint() != inst2.UsersFingerprint() {
+		t.Fatalf("users fingerprint must be stable under reordering: %s vs %s", inst1.UsersFingerprint(), inst2.UsersFingerprint())
+	}
+}
+
+func TestBindTo(t *testing.T) {
+	tests := []struct {
+		listen string
+		want   string
+	}{
+		{"", "0.0.0.0:8443"},
+		{"127.0.0.1", "127.0.0.1:8443"},
+		{"::", "[::]:8443"},
+		{"2001:db8::1", "[2001:db8::1]:8443"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.listen, func(t *testing.T) {
+			got := Instance{Listen: tc.listen, Port: 8443}.BindTo()
+			if got != tc.want {
+				t.Fatalf("BindTo(%q) = %q, want %q", tc.listen, got, tc.want)
+			}
+		})
+	}
+}

+ 84 - 0
internal/web/job/tuic_job.go

@@ -0,0 +1,84 @@
+package job
+
+import (
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+type TuicJob struct {
+	inboundService service.InboundService
+}
+
+func NewTuicJob() *TuicJob {
+	return new(TuicJob)
+}
+
+func (j *TuicJob) Run() {
+	desired, err := j.inboundService.DesiredTuicInstances()
+	if err != nil {
+		logger.Warning("tuic job: get desired instances failed:", err)
+		return
+	}
+
+	activeTags := make([]string, 0, len(desired))
+	for _, inst := range desired {
+		activeTags = append(activeTags, inst.Tag)
+	}
+
+	mgr := tuic.GetManager()
+	mgr.Reconcile(desired)
+
+	deltas := mgr.CollectTraffic()
+	onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
+
+	inboundUp := make(map[string]int64)
+	inboundDown := make(map[string]int64)
+	for _, d := range deltas {
+		inboundUp[d.Tag] += d.Up
+		inboundDown[d.Tag] += d.Down
+	}
+
+	traffics := make([]*xray.Traffic, 0, len(inboundUp))
+	for tag, up := range inboundUp {
+		traffics = append(traffics, &xray.Traffic{
+			IsInbound: true,
+			Tag:       tag,
+			Up:        up,
+			Down:      inboundDown[tag],
+		})
+	}
+
+	// Build zero-byte client traffic entries for active clients so adjustTraffics can
+	// activate delayed-start expiryTime for TUIC clients without inflating traffic.
+	clientTraffics := make([]*xray.ClientTraffic, 0, len(onlineEmails))
+	for _, email := range onlineEmails {
+		clientTraffics = append(clientTraffics, &xray.ClientTraffic{
+			Email: email,
+			Up:    0,
+			Down:  0,
+		})
+	}
+
+	if len(traffics) > 0 || len(clientTraffics) > 0 {
+		needRestart, _, err := j.inboundService.AddTraffic(traffics, clientTraffics)
+		if err != nil {
+			logger.Warning("tuic job: add traffic failed:", err)
+		} else if needRestart {
+			if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
+				mgr.Reconcile(desired)
+			}
+		}
+	}
+
+	if len(onlineEmails) > 0 {
+		if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
+			logger.Warning("tuic job: bump last online for tuic clients failed:", err)
+		}
+	}
+
+	j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
+}

+ 40 - 2
internal/web/runtime/local.go

@@ -12,6 +12,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
@@ -84,6 +85,13 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
 		}
 		return err
 	}
+	if ib.Protocol == model.TUIC {
+		inst, ok := tuic.InstanceFromInbound(ib)
+		if !ok {
+			return nil
+		}
+		return tuic.GetManager().Ensure(inst)
+	}
 	body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", "  ")
 	if err != nil {
 		return err
@@ -108,6 +116,10 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
 		}
 		return nil
 	}
+	if ib.Protocol == model.TUIC {
+		tuic.GetManager().Remove(ib.Id)
+		return nil
+	}
 	return l.withAPI(func(api *xray.XrayAPI) error {
 		return api.DelInbound(ib.Tag)
 	})
@@ -120,6 +132,9 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
 	if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG {
 		return l.updateAmneziaWGInbound(ctx, oldIb, newIb)
 	}
+	if oldIb.Protocol == model.TUIC || newIb.Protocol == model.TUIC {
+		return l.updateTuicInbound(ctx, oldIb, newIb)
+	}
 	_ = l.DelInbound(ctx, oldIb)
 	if !newIb.Enable {
 		return nil
@@ -210,8 +225,31 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
 	})
 }
 
+func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
+	if oldIb.Protocol == model.TUIC && newIb.Protocol != model.TUIC {
+		tuic.GetManager().Remove(oldIb.Id)
+		if !newIb.Enable {
+			return nil
+		}
+		return l.AddInbound(ctx, newIb)
+	}
+	if oldIb.Protocol != model.TUIC {
+		_ = l.DelInbound(ctx, oldIb)
+	}
+	if !newIb.Enable {
+		tuic.GetManager().Remove(newIb.Id)
+		return nil
+	}
+	inst, ok := tuic.InstanceFromInbound(newIb)
+	if !ok {
+		tuic.GetManager().Remove(newIb.Id)
+		return nil
+	}
+	return tuic.GetManager().Ensure(inst)
+}
+
 func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
-	if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
+	if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
 		return nil
 	}
 	return l.withAPI(func(api *xray.XrayAPI) error {
@@ -220,7 +258,7 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string
 }
 
 func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
-	if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
+	if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
 		return nil
 	}
 	return l.withAPI(func(api *xray.XrayAPI) error {

+ 7 - 0
internal/web/service/client_crud.go

@@ -408,6 +408,13 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
 		if c.Secret == "" {
 			c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
 		}
+	case model.TUIC:
+		if c.ID == "" {
+			c.ID = uuid.NewString()
+		}
+		if c.Password == "" {
+			c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
+		}
 	}
 	return nil
 }

+ 16 - 0
internal/web/service/client_inbound_apply.go

@@ -451,6 +451,16 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			if client.AdTag != "" && !model.ValidMtprotoAdTag(client.AdTag) {
 				return false, common.NewError("mtproto client ad tag must be 32 hex characters")
 			}
+		case "tuic":
+			if client.ID == "" {
+				return false, common.NewError("empty client ID")
+			}
+			if client.Password == "" {
+				return false, common.NewError("tuic client requires a password")
+			}
+			if client.Email == "" {
+				return false, common.NewError("empty client email")
+			}
 		default:
 			if client.ID == "" {
 				return false, common.NewError("empty client ID")
@@ -563,6 +573,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			inboundSvc.applyLocalMtproto(oldInbound.Id)
 		} else if oldInbound.Protocol == model.AmneziaWG {
 			inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
+		} else if oldInbound.Protocol == model.TUIC {
+			inboundSvc.applyLocalTuic(oldInbound.Id)
 		} else {
 			for _, client := range clients {
 				if len(client.Email) == 0 {
@@ -986,6 +998,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 				inboundSvc.applyLocalMtproto(oldInbound.Id)
 			} else if oldInbound.Protocol == model.AmneziaWG {
 				inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
+			} else if oldInbound.Protocol == model.TUIC {
+				inboundSvc.applyLocalTuic(oldInbound.Id)
 			} else {
 				if oldClients[clientIndex].Enable {
 					err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
@@ -1170,6 +1184,8 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 				// Same reasoning as MTProto above: the interface config is
 				// regenerated from the full peer set, so any delete re-applies it.
 				inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
+			} else if oldInbound.Protocol == model.TUIC {
+				inboundSvc.applyLocalTuic(oldInbound.Id)
 			} else if needApiDel {
 				// Local inbound: a disabled client isn't in the running Xray, so only
 				// a live one (needApiDel) needs an API removal.

+ 50 - 6
internal/web/service/inbound.go

@@ -22,6 +22,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -345,7 +346,8 @@ type InboundOption struct {
 	// AwgServer carries the full AmneziaWG server block (keys, subnet,
 	// obfuscation params) so the clients page can render a downloadable
 	// per-client .conf without a second round trip.
-	AwgServer *amneziawg.ServerSettings `json:"awgServer,omitempty"`
+	AwgServer  *amneziawg.ServerSettings `json:"awgServer,omitempty"`
+	TuicServer *tuic.TuicServerSettings  `json:"tuicServer,omitempty"`
 	// Hosting node; nil for this panel's own inbounds. Lets the clients
 	// page map a node filter onto inbound IDs (#4997).
 	NodeId *int `json:"nodeId,omitempty"`
@@ -412,6 +414,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			WgDns:             wgDns,
 			MtprotoDomain:     inboundMtprotoDomain(r.Protocol, r.Settings),
 			AwgServer:         inboundAmneziaWGServer(r.Protocol, r.Settings),
+			TuicServer:        inboundTuicServer(r.Protocol, r.Settings),
 			NodeId:            r.NodeId,
 			NodeAddress:       r.NodeAddress,
 			Listen:            r.Listen,
@@ -506,6 +509,21 @@ func inboundAmneziaWGServer(protocol string, settings string) *amneziawg.ServerS
 	return &redacted
 }
 
+func inboundTuicServer(protocol string, settings string) *tuic.TuicServerSettings {
+	if protocol != string(model.TUIC) || strings.TrimSpace(settings) == "" {
+		return nil
+	}
+	var parsed struct {
+		Server *tuic.TuicServerSettings `json:"server"`
+	}
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
+		return nil
+	}
+	redacted := *parsed.Server
+	redacted.PrivateKey = ""
+	return &redacted
+}
+
 // inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
 // the clients UI to seed a new mtproto client's secret with the right fronting
 // hostname.
@@ -1181,6 +1199,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 			if client.AdTag != "" && !model.ValidMtprotoAdTag(client.AdTag) {
 				return inbound, false, common.NewError("mtproto client ad tag must be 32 hex characters")
 			}
+		case "tuic":
+			if client.ID == "" {
+				return inbound, false, common.NewError("empty client ID")
+			}
+			if client.Password == "" {
+				return inbound, false, common.NewError("tuic client requires a password")
+			}
+			if client.Email == "" {
+				return inbound, false, common.NewError("empty client email")
+			}
 		default:
 			if client.ID == "" {
 				return inbound, false, common.NewError("empty client ID")
@@ -1271,7 +1299,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 				if push {
 					payload := inbound
 					pushable := true
-					if inbound.Protocol == model.MTProto {
+					if inbound.Protocol == model.MTProto || inbound.Protocol == model.TUIC {
 						if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
 							payload = built
 						} else {
@@ -1285,7 +1313,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 								logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
 							} else {
 								logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
-								needRestart = true
+								if inbound.Protocol != model.MTProto && inbound.Protocol != model.TUIC {
+									needRestart = true
+								}
 							}
 						}
 					}
@@ -1629,6 +1659,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 		}
 	}
+	if inbound.Protocol == model.TUIC {
+		for _, client := range clients {
+			if client.ID == "" {
+				return inbound, false, common.NewError("empty client ID")
+			}
+			if client.Password == "" {
+				return inbound, false, common.NewError("tuic client requires a password")
+			}
+			if client.Email == "" {
+				return inbound, false, common.NewError("empty client email")
+			}
+		}
+	}
+
 	// Grandfather a row that was already stored incomplete so it stays editable;
 	// only a save that breaks a previously valid TLS block is refused.
 	if !s.FromNodeSync {
@@ -1801,7 +1845,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 			if !push {
 				needRestart = true
-			} else if oldProtocol == model.MTProto || oldInbound.Protocol == model.MTProto {
+			} else if oldProtocol == model.MTProto || oldInbound.Protocol == model.MTProto || oldProtocol == model.TUIC || oldInbound.Protocol == model.TUIC {
 				oldSnapshot := *oldInbound
 				oldSnapshot.Tag = tag
 				oldSnapshot.Protocol = oldProtocol
@@ -1815,14 +1859,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 						pushable = false
 					}
 				}
-				newProtocolIsMtproto := oldInbound.Protocol == model.MTProto
+				newProtocolIsSidecar := oldInbound.Protocol == model.MTProto || oldInbound.Protocol == model.TUIC
 				if pushable {
 					postCommitApply = func() {
 						if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
 							logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
 						} else {
 							logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
-							if !newProtocolIsMtproto {
+							if !newProtocolIsSidecar {
 								needRestart = true
 							}
 						}

+ 3 - 0
internal/web/service/inbound_clients.go

@@ -233,6 +233,9 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
 		target.Auth = s.generateRandomCredential(targetProtocol)
 	case model.MTProto:
 		target.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(targetInbound.Settings))
+	case model.TUIC:
+		target.ID = uuid.NewString()
+		target.Password = s.generateRandomCredential(targetProtocol)
 	default:
 		target.ID = s.generateRandomCredential(targetProtocol)
 	}

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

@@ -0,0 +1,66 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestBuildTargetClientFromSourceTuic(t *testing.T) {
+	s := &InboundService{}
+	source := model.Client{
+		Email:    "[email protected]",
+		ID:       "old-uuid",
+		Password: "old-password",
+	}
+	targetInbound := &model.Inbound{
+		Protocol: model.TUIC,
+	}
+
+	target, err := s.buildTargetClientFromSource(source, targetInbound, "[email protected]", "")
+	if err != nil {
+		t.Fatalf("buildTargetClientFromSource failed: %v", err)
+	}
+
+	if target.ID == "" || target.ID == "old-uuid" {
+		t.Fatalf("expected new UUID for TUIC client, got %q", target.ID)
+	}
+	if target.Password == "" || target.Password == "old-password" {
+		t.Fatalf("expected new password for TUIC client, got %q", target.Password)
+	}
+}
+
+func TestAddInboundTuicClientValidation(t *testing.T) {
+	setupConflictDB(t)
+	s := &InboundService{}
+	ib := &model.Inbound{
+		Tag:      "tuic-test-1",
+		Protocol: model.TUIC,
+		Settings: `{"clients":[{"id":"uuid-1","password":""}]}`,
+	}
+	_, _, err := s.AddInbound(ib)
+	if err == nil || strings.TrimSpace(err.Error()) != "tuic client requires a password" {
+		t.Fatalf("expected 'tuic client requires a password' error, got %v", err)
+	}
+
+	ibNoID := &model.Inbound{
+		Tag:      "tuic-test-2",
+		Protocol: model.TUIC,
+		Settings: `{"clients":[{"id":"","password":"pass"}]}`,
+	}
+	_, _, errNoID := s.AddInbound(ibNoID)
+	if errNoID == nil || strings.TrimSpace(errNoID.Error()) != "empty client ID" {
+		t.Fatalf("expected 'empty client ID' error, got %v", errNoID)
+	}
+
+	ibNoEmail := &model.Inbound{
+		Tag:      "tuic-test-3",
+		Protocol: model.TUIC,
+		Settings: `{"clients":[{"id":"uuid-3","password":"pass","email":""}]}`,
+	}
+	_, _, errNoEmail := s.AddInbound(ibNoEmail)
+	if errNoEmail == nil || strings.TrimSpace(errNoEmail.Error()) != "empty client email" {
+		t.Fatalf("expected 'empty client email' error, got %v", errNoEmail)
+	}
+}

+ 4 - 0
internal/web/service/inbound_traffic_apply.go

@@ -83,6 +83,10 @@ func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool
 			s.applyLocalAmneziaWG(plan.inbound.Id)
 			continue
 		}
+		if plan.inbound.Protocol == model.TUIC {
+			s.applyLocalTuic(plan.inbound.Id)
+			continue
+		}
 		rt, err := s.runtimeFor(&plan.inbound)
 		if err == nil {
 			switch plan.action {

+ 87 - 0
internal/web/service/inbound_tuic.go

@@ -0,0 +1,87 @@
+package service
+
+import (
+	"context"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
+	db := database.GetDB()
+	var inbounds []*model.Inbound
+	err := db.Model(model.Inbound{}).
+		Where("protocol = ? AND enable = ? AND node_id IS NULL", model.TUIC, true).
+		Find(&inbounds).Error
+	if err != nil {
+		return nil, err
+	}
+	if len(inbounds) == 0 {
+		return nil, nil
+	}
+
+	ids := make([]int, 0, len(inbounds))
+	for _, ib := range inbounds {
+		ids = append(ids, ib.Id)
+	}
+	var disabledRows []xray.ClientTraffic
+	err = db.Model(xray.ClientTraffic{}).
+		Where("inbound_id IN ? AND enable = ?", ids, false).
+		Select("inbound_id", "email").
+		Find(&disabledRows).Error
+	if err != nil {
+		return nil, err
+	}
+	disabled := make(map[int]map[string]struct{}, len(disabledRows))
+	for _, row := range disabledRows {
+		if disabled[row.InboundId] == nil {
+			disabled[row.InboundId] = map[string]struct{}{}
+		}
+		disabled[row.InboundId][row.Email] = struct{}{}
+	}
+
+	instances := make([]tuic.Instance, 0, len(inbounds))
+	for _, ib := range inbounds {
+		inst, ok := tuic.InstanceFromInbound(ib)
+		if !ok {
+			continue
+		}
+		if off := disabled[ib.Id]; len(off) > 0 {
+			kept := make([]tuic.TuicClientSettings, 0, len(inst.Clients))
+			for _, c := range inst.Clients {
+				if _, skip := off[c.Email]; !skip {
+					kept = append(kept, c)
+				}
+			}
+			inst.Clients = kept
+		}
+		if len(inst.Clients) == 0 {
+			continue
+		}
+		instances = append(instances, inst)
+	}
+	return instances, nil
+}
+
+func (s *InboundService) applyLocalTuic(inboundId int) {
+	inbound, err := s.GetInbound(inboundId)
+	if err != nil || inbound == nil || inbound.Protocol != model.TUIC || inbound.NodeID != nil {
+		return
+	}
+	rt, err := s.runtimeFor(inbound)
+	if err != nil {
+		return
+	}
+	payload := inbound
+	if inbound.Enable {
+		if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
+			payload = built
+		}
+	}
+	if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
+		logger.Debug("tuic: immediate client apply failed for inbound", inboundId, ":", err)
+	}
+}

+ 1 - 1
internal/web/service/port_conflict.go

@@ -24,7 +24,7 @@ const (
 func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
 	// protocols that ignore streamSettings entirely.
 	switch protocol {
-	case model.Hysteria, model.WireGuard, model.AmneziaWG:
+	case model.Hysteria, model.WireGuard, model.AmneziaWG, model.TUIC:
 		return transportUDP
 	case model.MTProto:
 		return transportTCP

+ 1 - 1
internal/web/service/xray.go

@@ -180,7 +180,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		if inbound.NodeID != nil {
 			continue
 		}
-		if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
+		if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG || inbound.Protocol == model.TUIC {
 			continue
 		}
 		settings := map[string]any{}

+ 19 - 2
internal/web/translation/ar-EG.json

@@ -675,7 +675,8 @@
         "bytesPerSec": "بايت في الثانية",
         "bytesPerSecTip": "حد السرعة (بايت/ثانية) المطبّق على مرور الـ fallback بعد العتبة، عشان الفحوصات ماتقدرش تستخدم سيرفرك كنطاق ترددي مجاني للهدف. 0 = بلا حد (بيعطّل الاتجاه ده).",
         "burstBytesPerSec": "بايت في الثانية للدفقة",
-        "burstBytesPerSecTip": "السماح بدفقات قصيرة فوق المعدل الثابت (حجم token-bucket). لو أقل من بايت في الثانية بيترفع ليطابقه."
+        "burstBytesPerSecTip": "السماح بدفقات قصيرة فوق المعدل الثابت (حجم token-bucket). لو أقل من بايت في الثانية بيترفع ليطابقه.",
+        "autoFill": "الملء التلقائي"
       },
       "info": {
         "mode": "الوضع",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "تكوين Peer {n}"
       },
-      "sniffingDestOverride": "تجاوز الوجهة"
+      "sniffingDestOverride": "تجاوز الوجهة",
+      "advancedTitle": "الإعدادات المتقدمة"
     },
     "clients": {
       "tabBasics": "أساسي",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "إعدادات AmneziaWG",
+      "tuicConfig": "إعدادات TUIC (Clash)",
       "mtprotoSecret": "سر MTProto",
       "mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
       "mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
@@ -915,6 +918,7 @@
       "renewOnDay": "يوم التجديد",
       "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
       "renewsUsed": "التجديدات المستخدمة",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "التحكم في الازدحام",
+        "alpn": "ALPN",
+        "udpRelayMode": "وضع ترحيل UDP",
+        "zeroRttHandshake": "مصافحة 0-RTT",
+        "logLevel": "مستوى السجل",
+        "logLevelHint": "يتم استنتاج حالة الاتصال وآخر ظهور و«البدء بعد أول استخدام» من سطور Info الخاصة بالعملية الجانبية؛ اختيار Warn أو Error يعطّلها لهذا الإنباوند.",
+        "maxIdleTime": "أقصى وقت خمول (ثوانٍ)",
+        "authTimeout": "مهلة المصادقة (ثوانٍ)",
+        "maxUdpRelayPacketSize": "أقصى حجم لحزمة UDP",
+        "sni": "SNI",
+        "sniHint": "تجاوز إشارة اسم الخادم"
+      },
       "tun": {
         "userLevel": "مستوى المستخدم"
       },

+ 18 - 1
internal/web/translation/en-US.json

@@ -410,6 +410,7 @@
         "stream": "Stream",
         "streamHelp": "Xray stream block wrapper:"
       },
+      "advancedTitle": "Advanced Settings",
       "subSortIndex": "Sub order",
       "inboundInfo": "Inbound Information",
       "exportInbound": "Export Inbound",
@@ -457,6 +458,7 @@
         "moreIssues": "{message}  (+{count} more)"
       },
       "form": {
+        "autoFill": "Auto Fill",
         "moveUp": "Move up",
         "moveDown": "Move down",
         "addAll": "Add all",
@@ -768,7 +770,7 @@
       "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
       "limitIpDisabled": "The IP limit feature is disabled on this server.",
       "password": "Password",
-      "passwordDesc": "Only used by Trojan and Shadowsocks clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
+      "passwordDesc": "Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
       "subId": "Subscription ID",
       "online": "Online",
       "email": "Email",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG config",
+      "tuicConfig": "TUIC config (Clash)",
       "mtprotoSecret": "MTProto secret",
       "mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
       "mtprotoAdTag": "Ad-tag (sponsored channel)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Renew on day",
       "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
       "renewsUsed": "Renewals used",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds."
     },
     "groups": {
@@ -2078,6 +2082,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Congestion Control",
+        "alpn": "ALPN",
+        "udpRelayMode": "UDP Relay Mode",
+        "zeroRttHandshake": "Zero-RTT Handshake",
+        "logLevel": "Log Level",
+        "logLevelHint": "Online status, last online and \"start after first use\" are read from the sidecar's Info lines; Warn or Error turns them off for this inbound.",
+        "maxIdleTime": "Max Idle Time (s)",
+        "authTimeout": "Auth Timeout (s)",
+        "maxUdpRelayPacketSize": "Max UDP Packet Size",
+        "sni": "SNI",
+        "sniHint": "Server Name Indication override"
+      },
       "tun": {
         "userLevel": "User Level"
       },

+ 19 - 2
internal/web/translation/es-ES.json

@@ -675,7 +675,8 @@
           "node": "Dirección del nodo",
           "listen": "Dirección de escucha del inbound",
           "custom": "Personalizada"
-        }
+        },
+        "autoFill": "Autocompletar"
       },
       "info": {
         "mode": "Modo",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Config Peer {n}"
       },
-      "sniffingDestOverride": "Anulación de destino"
+      "sniffingDestOverride": "Anulación de destino",
+      "advancedTitle": "Configuración avanzada"
     },
     "clients": {
       "tabBasics": "Básico",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Configuración de AmneziaWG",
+      "tuicConfig": "Configuración de TUIC (Clash)",
       "mtprotoSecret": "Secreto MTProto",
       "mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",
       "mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Renovar el día",
       "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
       "renewsUsed": "Renovaciones usadas",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Control de congestión",
+        "alpn": "ALPN",
+        "udpRelayMode": "Modo de retransmisión UDP",
+        "zeroRttHandshake": "Apretón de manos 0-RTT",
+        "logLevel": "Nivel de registro",
+        "logLevelHint": "El estado en línea, la última conexión y «iniciar tras el primer uso» se leen de las líneas Info del sidecar; Warn o Error los desactivan para este inbound.",
+        "maxIdleTime": "Tiempo máx. de inactividad (s)",
+        "authTimeout": "Tiempo de espera de autenticación (s)",
+        "maxUdpRelayPacketSize": "Tamaño máx. de paquete UDP",
+        "sni": "SNI",
+        "sniHint": "Sobrescritura de Server Name Indication"
+      },
       "tun": {
         "userLevel": "Nivel de Usuario"
       },

+ 19 - 2
internal/web/translation/fa-IR.json

@@ -675,7 +675,8 @@
           "node": "آدرس نود",
           "listen": "آدرس شنود ورودی",
           "custom": "سفارشی"
-        }
+        },
+        "autoFill": "تکمیل خودکار"
       },
       "info": {
         "mode": "حالت",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "پیکربندی Peer {n}"
       },
-      "sniffingDestOverride": "بازنویسی مقصد"
+      "sniffingDestOverride": "بازنویسی مقصد",
+      "advancedTitle": "تنظیمات پیشرفته"
     },
     "clients": {
       "tabBasics": "پایه",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "پیکربندی AmneziaWG",
+      "tuicConfig": "پیکربندی TUIC (Clash)",
       "mtprotoSecret": "سکرت MTProto",
       "mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
       "mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
@@ -915,6 +918,7 @@
       "renewOnDay": "روز تمدید",
       "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
       "renewsUsed": "تمدیدهای استفاده‌شده",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "کنترل ازدحام",
+        "alpn": "ALPN",
+        "udpRelayMode": "حالت رله UDP",
+        "zeroRttHandshake": "دست دادن 0-RTT",
+        "logLevel": "سطح گزارش",
+        "logLevelHint": "وضعیت آنلاین، آخرین اتصال و «شروع پس از اولین استفاده» از خطوط Info سایدکار خوانده می‌شوند؛ Warn یا Error آن‌ها را برای این اینباند غیرفعال می‌کند.",
+        "maxIdleTime": "حداکثر زمان بیکاری (ثانیه)",
+        "authTimeout": "مهلت زمانی احراز هویت (ثانیه)",
+        "maxUdpRelayPacketSize": "حداکثر اندازه بسته UDP",
+        "sni": "SNI",
+        "sniHint": "بازنویسی نشانگر نام سرور"
+      },
       "tun": {
         "userLevel": "سطح کاربر"
       },

+ 19 - 2
internal/web/translation/id-ID.json

@@ -675,7 +675,8 @@
         "bytesPerSec": "Byte per detik",
         "bytesPerSecTip": "Batas kecepatan (byte/detik) yang diterapkan pada lalu lintas fallback setelah ambang batas, agar probe tidak dapat memakai server Anda sebagai bandwidth gratis menuju target. 0 = tanpa batas (menonaktifkan arah ini).",
         "burstBytesPerSec": "Byte per detik burst",
-        "burstBytesPerSecTip": "Kelonggaran untuk burst singkat di atas laju tetap (ukuran token-bucket). Jika lebih kecil dari Byte per detik, nilainya dinaikkan agar sama."
+        "burstBytesPerSecTip": "Kelonggaran untuk burst singkat di atas laju tetap (ukuran token-bucket). Jika lebih kecil dari Byte per detik, nilainya dinaikkan agar sama.",
+        "autoFill": "Isi Otomatis"
       },
       "info": {
         "mode": "Mode",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Konfig Peer {n}"
       },
-      "sniffingDestOverride": "Penggantian tujuan"
+      "sniffingDestOverride": "Penggantian tujuan",
+      "advancedTitle": "Pengaturan Lanjutan"
     },
     "clients": {
       "tabBasics": "Dasar",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Konfigurasi AmneziaWG",
+      "tuicConfig": "Konfigurasi TUIC (Clash)",
       "mtprotoSecret": "Secret MTProto",
       "mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",
       "mtprotoAdTag": "Ad-tag (kanal bersponsor)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Perpanjang pada tanggal",
       "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
       "renewsUsed": "Perpanjangan terpakai",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Kontrol Kemacetan",
+        "alpn": "ALPN",
+        "udpRelayMode": "Mode Relai UDP",
+        "zeroRttHandshake": "Jabat Tangan 0-RTT",
+        "logLevel": "Tingkat Log",
+        "logLevelHint": "Status online, terakhir online, dan \"mulai setelah penggunaan pertama\" dibaca dari baris Info sidecar; Warn atau Error menonaktifkannya untuk inbound ini.",
+        "maxIdleTime": "Waktu Diam Maks (dtk)",
+        "authTimeout": "Batas Waktu Otentikasi (dtk)",
+        "maxUdpRelayPacketSize": "Ukuran Paket UDP Maks",
+        "sni": "SNI",
+        "sniHint": "Penggantian Server Name Indication"
+      },
       "tun": {
         "userLevel": "Level Pengguna"
       },

+ 19 - 2
internal/web/translation/ja-JP.json

@@ -675,7 +675,8 @@
           "node": "ノードアドレス",
           "listen": "インバウンドのリッスンアドレス",
           "custom": "カスタム"
-        }
+        },
+        "autoFill": "自動入力"
       },
       "info": {
         "mode": "モード",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Peer {n} 設定"
       },
-      "sniffingDestOverride": "宛先のオーバーライド"
+      "sniffingDestOverride": "宛先のオーバーライド",
+      "advancedTitle": "詳細設定"
     },
     "clients": {
       "tabBasics": "基本",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG 設定",
+      "tuicConfig": "TUIC 設定 (Clash)",
       "mtprotoSecret": "MTProto シークレット",
       "mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",
       "mtprotoAdTag": "広告タグ(スポンサーチャンネル)",
@@ -915,6 +918,7 @@
       "renewOnDay": "更新する日",
       "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
       "renewsUsed": "使用済み更新回数",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。"
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "輻輳制御",
+        "alpn": "ALPN",
+        "udpRelayMode": "UDP リレーモード",
+        "zeroRttHandshake": "0-RTT ハンドシェイク",
+        "logLevel": "ログレベル",
+        "logLevelHint": "オンライン状態、最終オンライン、「初回使用後に開始」はサイドカーの Info 行から検出されます。Warn または Error を選ぶとこのインバウンドでは無効になります。",
+        "maxIdleTime": "最大アイドル時間 (秒)",
+        "authTimeout": "認証タイムアウト (秒)",
+        "maxUdpRelayPacketSize": "最大 UDP パケットサイズ",
+        "sni": "SNI",
+        "sniHint": "Server Name Indication の上書き"
+      },
       "tun": {
         "userLevel": "ユーザーレベル"
       },

+ 19 - 2
internal/web/translation/pt-BR.json

@@ -675,7 +675,8 @@
           "node": "Endereço do nó",
           "listen": "Endereço de escuta do inbound",
           "custom": "Personalizada"
-        }
+        },
+        "autoFill": "Preenchimento automático"
       },
       "info": {
         "mode": "Modo",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Config Peer {n}"
       },
-      "sniffingDestOverride": "Substituição de destino"
+      "sniffingDestOverride": "Substituição de destino",
+      "advancedTitle": "Configurações avançadas"
     },
     "clients": {
       "tabBasics": "Básico",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Configuração do AmneziaWG",
+      "tuicConfig": "Configuração do TUIC (Clash)",
       "mtprotoSecret": "Segredo MTProto",
       "mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",
       "mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Renovar no dia",
       "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
       "renewsUsed": "Renovações usadas",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Controle de congestionamento",
+        "alpn": "ALPN",
+        "udpRelayMode": "Modo de retransmissão UDP",
+        "zeroRttHandshake": "Handshake 0-RTT",
+        "logLevel": "Nível de registro",
+        "logLevelHint": "Status online, último acesso e \"iniciar após o primeiro uso\" são lidos das linhas Info do sidecar; Warn ou Error os desativam para este inbound.",
+        "maxIdleTime": "Tempo máx. ocioso (s)",
+        "authTimeout": "Tempo limite de autenticação (s)",
+        "maxUdpRelayPacketSize": "Tamanho máx. do pacote UDP",
+        "sni": "SNI",
+        "sniHint": "Substituição do Server Name Indication"
+      },
       "tun": {
         "userLevel": "Nível do Usuário"
       },

+ 18 - 1
internal/web/translation/ru-RU.json

@@ -410,6 +410,7 @@
         "stream": "Stream",
         "streamHelp": "Обёртка блока stream Xray:"
       },
+      "advancedTitle": "Дополнительные настройки",
       "subSortIndex": "Порядок",
       "inboundInfo": "Информация о подключении",
       "exportInbound": "Экспорт подключений",
@@ -457,6 +458,7 @@
         "moreIssues": "{message}  (+{count} ещё)"
       },
       "form": {
+        "autoFill": "Автозаполнение",
         "echSockopt": "ECH Sockopt",
         "echSockoptTip": "Параметры сокета для соединения, через которое Xray получает список конфигураций ECH (например, можно направить запрос через исходящее dialerProxy). Оставьте отключённым для значений по умолчанию.",
         "curvePreferences": "Предпочтения кривых",
@@ -768,7 +770,7 @@
       "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
       "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
       "password": "Пароль",
-      "passwordDesc": "Используется только клиентами Trojan и Shadowsocks; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
+      "passwordDesc": "Используется клиентами Trojan, Shadowsocks и TUIC; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
       "subId": "ID подписки",
       "online": "В сети",
       "email": "Email",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (секунды)",
       "tunnelKeepAliveHint": "Как часто клиент шлёт keepalive-пакет. Обычное значение 25: оно держит NAT-маппинг открытым и возвращает простаивающий пир сам после любого разрыва, включая рестарт панели. 0 отключает — тогда молчащий клиент остаётся отключённым до первого своего пакета.",
       "amneziaWgConfig": "Конфиг AmneziaWG",
+      "tuicConfig": "Конфиг TUIC (Clash)",
       "mtprotoSecret": "Секрет MTProto",
       "mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
       "mtprotoAdTag": "Рекламный тег (спонсорский канал)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Продлевать числа",
       "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
       "renewsUsed": "Продлений израсходовано",
+      "tuicTotalGBDesc": "TUIC не поддерживает персональные квоты трафика; задайте лимит на уровне инбаунда.",
       "bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Контроль перегрузок",
+        "alpn": "ALPN",
+        "udpRelayMode": "Режим ретрансляции UDP",
+        "zeroRttHandshake": "0-RTT рукопожатие",
+        "logLevel": "Уровень логирования",
+        "logLevelHint": "Статус онлайн, «последний раз онлайн» и «старт после первого использования» читаются из строк Info сайдкара; Warn или Error отключают их для этого инбаунда.",
+        "maxIdleTime": "Макс. время простоя (сек)",
+        "authTimeout": "Таймаут аутентификации (сек)",
+        "maxUdpRelayPacketSize": "Макс. размер пакета UDP",
+        "sni": "SNI",
+        "sniHint": "Переопределение Server Name Indication"
+      },
       "tun": {
         "userLevel": "Уровень пользователя"
       },

+ 19 - 2
internal/web/translation/tr-TR.json

@@ -675,7 +675,8 @@
         "bytesPerSec": "Saniye Başına Bayt",
         "bytesPerSecTip": "Eşik aşıldıktan sonra fallback trafiğine uygulanan hız sınırı (bayt/sn); böylece sondalar sunucunuzu hedefe ücretsiz bant genişliği olarak kullanamaz. 0 = sınır yok (bu yönü devre dışı bırakır).",
         "burstBytesPerSec": "Saniye Başına Patlama Baytı",
-        "burstBytesPerSecTip": "Sabit hızın üzerindeki kısa patlamalar için pay (token-bucket boyutu). Saniye Başına Bayt değerinden düşükse ona eşitlenecek şekilde yükseltilir."
+        "burstBytesPerSecTip": "Sabit hızın üzerindeki kısa patlamalar için pay (token-bucket boyutu). Saniye Başına Bayt değerinden düşükse ona eşitlenecek şekilde yükseltilir.",
+        "autoFill": "Otomatik Doldur"
       },
       "info": {
         "mode": "Mod",
@@ -694,7 +695,8 @@
         "peerNumber": "Eş {n}",
         "peerNumberConfig": "Peer {n} Yapılandırması"
       },
-      "sniffingDestOverride": "Hedef geçersiz kılma"
+      "sniffingDestOverride": "Hedef geçersiz kılma",
+      "advancedTitle": "Gelişmiş Ayarlar"
     },
     "clients": {
       "tabBasics": "Temel",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG Yapılandırması",
+      "tuicConfig": "TUIC Yapılandırması (Clash)",
       "mtprotoSecret": "MTProto sırrı",
       "mtprotoSecretHint": "İstemcinin FakeTLS sırrı. Değiştirmek için yeniden oluşturun.",
       "mtprotoAdTag": "Reklam etiketi (sponsorlu kanal)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Yenileme günü",
       "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
       "renewsUsed": "Kullanılan yenileme",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Tıkanıklık Kontrolü",
+        "alpn": "ALPN",
+        "udpRelayMode": "UDP İletim Modu",
+        "zeroRttHandshake": "0-RTT El Sıkışması",
+        "logLevel": "Günlük Seviyesi",
+        "logLevelHint": "Çevrimiçi durumu, son görülme ve \"ilk kullanımdan sonra başlat\" sidecar'ın Info satırlarından okunur; Warn veya Error bunları bu inbound için kapatır.",
+        "maxIdleTime": "Maksimum Boşta Kalma Süresi (sn)",
+        "authTimeout": "Kimlik Doğrulama Zaman Aşımı (sn)",
+        "maxUdpRelayPacketSize": "Maksimum UDP Paket Boyutu",
+        "sni": "SNI",
+        "sniHint": "Sunucu Adı Belirtimi (SNI) geçersiz kılma"
+      },
       "tun": {
         "userLevel": "Kullanıcı Seviyesi"
       },

+ 19 - 2
internal/web/translation/uk-UA.json

@@ -675,7 +675,8 @@
           "node": "Адреса вузла",
           "listen": "Адреса прослуховування inbound",
           "custom": "Користувацька"
-        }
+        },
+        "autoFill": "Автозаповнення"
       },
       "info": {
         "mode": "Режим",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Конфіг Peer {n}"
       },
-      "sniffingDestOverride": "Перевизначення призначення"
+      "sniffingDestOverride": "Перевизначення призначення",
+      "advancedTitle": "Додаткові налаштування"
     },
     "clients": {
       "tabBasics": "Основні",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Конфігурація AmneziaWG",
+      "tuicConfig": "Конфігурація TUIC (Clash)",
       "mtprotoSecret": "Секрет MTProto",
       "mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
       "mtprotoAdTag": "Рекламний тег (спонсорський канал)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Подовжувати числа",
       "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
       "renewsUsed": "Подовжень витрачено",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Контроль перевантаження",
+        "alpn": "ALPN",
+        "udpRelayMode": "Режим ретрансляції UDP",
+        "zeroRttHandshake": "0-RTT рукостискання",
+        "logLevel": "Рівень журналювання",
+        "logLevelHint": "Статус онлайн, «востаннє онлайн» і «старт після першого використання» читаються з рядків Info сайдкара; Warn або Error вимикають їх для цього інбаунда.",
+        "maxIdleTime": "Макс. час простою (сек)",
+        "authTimeout": "Таймаут автентифікації (сек)",
+        "maxUdpRelayPacketSize": "Макс. розмір пакета UDP",
+        "sni": "SNI",
+        "sniHint": "Перевизначення Server Name Indication"
+      },
       "tun": {
         "userLevel": "Рівень користувача"
       },

+ 19 - 2
internal/web/translation/vi-VN.json

@@ -675,7 +675,8 @@
           "node": "Địa chỉ node",
           "listen": "Địa chỉ listen inbound",
           "custom": "Tùy chỉnh"
-        }
+        },
+        "autoFill": "Tự động điền"
       },
       "info": {
         "mode": "Chế độ",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Cấu hình Peer {n}"
       },
-      "sniffingDestOverride": "Ghi đè đích"
+      "sniffingDestOverride": "Ghi đè đích",
+      "advancedTitle": "Cài đặt nâng cao"
     },
     "clients": {
       "tabBasics": "Cơ bản",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Cấu hình AmneziaWG",
+      "tuicConfig": "Cấu hình TUIC (Clash)",
       "mtprotoSecret": "Secret MTProto",
       "mtprotoSecretHint": "Secret FakeTLS của client. Tạo lại để thay đổi.",
       "mtprotoAdTag": "Ad-tag (kênh tài trợ)",
@@ -915,6 +918,7 @@
       "renewOnDay": "Gia hạn vào ngày",
       "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
       "renewsUsed": "Số lần gia hạn đã dùng",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto."
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "Kiểm soát tắc nghẽn",
+        "alpn": "ALPN",
+        "udpRelayMode": "Chế độ chuyển tiếp UDP",
+        "zeroRttHandshake": "Bắt tay 0-RTT",
+        "logLevel": "Mức ghi nhật ký",
+        "logLevelHint": "Trạng thái trực tuyến, lần trực tuyến cuối và \"bắt đầu sau lần dùng đầu tiên\" được đọc từ các dòng Info của sidecar; Warn hoặc Error sẽ tắt chúng cho inbound này.",
+        "maxIdleTime": "Thời gian nhàn rỗi tối đa (giây)",
+        "authTimeout": "Thời gian chờ xác thực (giây)",
+        "maxUdpRelayPacketSize": "Kích thước gói UDP tối đa",
+        "sni": "SNI",
+        "sniHint": "Ghi đè Server Name Indication"
+      },
       "tun": {
         "userLevel": "Mức Người Dùng"
       },

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

@@ -675,7 +675,8 @@
           "listen": "入站监听地址",
           "custom": "自定义"
         },
-        "verifyPeerCertByName": "按名称验证对端证书"
+        "verifyPeerCertByName": "按名称验证对端证书",
+        "autoFill": "自动填充"
       },
       "info": {
         "mode": "模式",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Peer {n} 配置"
       },
-      "sniffingDestOverride": "目标覆盖"
+      "sniffingDestOverride": "目标覆盖",
+      "advancedTitle": "高级设置"
     },
     "clients": {
       "tabBasics": "基本",
@@ -768,7 +770,7 @@
       "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
       "limitIpDisabled": "此服务器已禁用 IP 限制功能。",
       "password": "密码",
-      "passwordDesc": "仅 Trojan 和 Shadowsocks 客户端使用;VLESS、VMess、Hysteria 和 WireGuard 会忽略此项。",
+      "passwordDesc": "用于 Trojan、Shadowsocks 和 TUIC 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。",
       "subId": "订阅 ID",
       "online": "在线",
       "email": "邮箱",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG 配置",
+      "tuicConfig": "TUIC 配置 (Clash)",
       "mtprotoSecret": "MTProto 密钥",
       "mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
       "mtprotoAdTag": "广告标签(赞助频道)",
@@ -915,6 +918,7 @@
       "renewOnDay": "按日期续期",
       "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
       "renewsUsed": "已用续期次数",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。"
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "拥塞控制算法",
+        "alpn": "ALPN",
+        "udpRelayMode": "UDP 转发模式",
+        "zeroRttHandshake": "0-RTT 握手",
+        "logLevel": "日志级别",
+        "logLevelHint": "在线状态、最后在线时间和“首次使用后开始”来自 sidecar 的 Info 日志行;选择 Warn 或 Error 会使此入站失去这些功能。",
+        "maxIdleTime": "最大空闲时间 (秒)",
+        "authTimeout": "认证超时时间 (秒)",
+        "maxUdpRelayPacketSize": "最大 UDP 数据包大小",
+        "sni": "SNI",
+        "sniHint": "服务器名称指示 (SNI) 覆盖"
+      },
       "tun": {
         "userLevel": "用户级别"
       },

+ 19 - 2
internal/web/translation/zh-TW.json

@@ -675,7 +675,8 @@
           "node": "節點地址",
           "listen": "入站監聽地址",
           "custom": "自訂"
-        }
+        },
+        "autoFill": "自動填入"
       },
       "info": {
         "mode": "模式",
@@ -694,7 +695,8 @@
         "peerNumber": "Peer {n}",
         "peerNumberConfig": "Peer {n} 設定"
       },
-      "sniffingDestOverride": "目標覆寫"
+      "sniffingDestOverride": "目標覆寫",
+      "advancedTitle": "進階設定"
     },
     "clients": {
       "tabBasics": "基本",
@@ -882,6 +884,7 @@
       "tunnelKeepAlive": "Keepalive (seconds)",
       "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG 設定",
+      "tuicConfig": "TUIC 設定 (Clash)",
       "mtprotoSecret": "MTProto 金鑰",
       "mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
       "mtprotoAdTag": "廣告標籤(贊助頻道)",
@@ -915,6 +918,7 @@
       "renewOnDay": "按日期續期",
       "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
       "renewsUsed": "已用續期次數",
+      "tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
       "bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。"
     },
     "groups": {
@@ -1960,6 +1964,19 @@
         "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
         "outboundObfuscationHint": "Must exactly match the server side parameters."
       },
+      "tuic": {
+        "congestionControl": "壅塞控制演算法",
+        "alpn": "ALPN",
+        "udpRelayMode": "UDP 轉發模式",
+        "zeroRttHandshake": "0-RTT 握手",
+        "logLevel": "日誌層級",
+        "logLevelHint": "線上狀態、最後上線時間和「首次使用後開始」來自 sidecar 的 Info 日誌行;選擇 Warn 或 Error 會使此入站失去這些功能。",
+        "maxIdleTime": "最大閒置時間 (秒)",
+        "authTimeout": "認證逾時時間 (秒)",
+        "maxUdpRelayPacketSize": "最大 UDP 封包大小",
+        "sni": "SNI",
+        "sniHint": "伺服器名稱指示 (SNI) 覆蓋"
+      },
       "tun": {
         "userLevel": "用戶級別"
       },

+ 7 - 0
internal/web/web.go

@@ -21,6 +21,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
@@ -293,6 +294,7 @@ const (
 	cadenceXrayTraffic   = "@every 5s"
 	cadenceMtproto       = "@every 10s"
 	cadenceAmneziaWG     = "@every 10s"
+	cadenceTuic          = "@every 10s"
 	cadenceClientIPScan  = "@every 10s"
 	cadenceNodeHeartbeat = "@every 5s"
 	cadenceNodeTraffic   = "@every 5s"
@@ -339,6 +341,10 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
 	_, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
 	go awgJob.Run()
 
+	tuicJob := job.NewTuicJob()
+	_, _ = s.cron.AddJob(cadenceTuic, tuicJob)
+	go tuicJob.Run()
+
 	// check client ips from log file every 10 sec
 	_, _ = s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
 
@@ -699,6 +705,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 		_ = s.xrayService.StopXray()
 		mtproto.GetManager().StopAll()
 		amneziawgnet.GetManager().StopAll()
+		tuic.GetManager().StopAll()
 		amneziawgnet.GetOutboundManager().StopAll()
 	}
 	if s.cron != nil {

+ 4 - 0
tools/openapigen/main.go

@@ -117,6 +117,10 @@ func run(root, outDir string) error {
 			Path:        resolveRel(root, "internal/amneziawg"),
 			StructAllow: setOf("ServerSettings"),
 		},
+		{
+			Path:        resolveRel(root, "internal/tuic"),
+			StructAllow: setOf("TuicServerSettings", "TuicClientSettings"),
+		},
 	}
 
 	schemas, aliases, err := walkPackages(requests)

+ 4 - 0
update.sh

@@ -1086,6 +1086,7 @@ update_x-ui() {
         # an inbound port with an outdated secret, silently breaking new clients.
         # The new panel respawns a clean mtg per inbound on next start.
         pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
+        pkill -f 'tuic-server.*-c .*bin/tuic/tuic_[0-9]+\.json' > /dev/null 2>&1 || true
         echo -e "${green}Removing old x-ui version...${plain}"
         rm ${xui_folder} -f > /dev/null 2>&1
         rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
@@ -1137,6 +1138,9 @@ update_x-ui() {
     elif [[ -f bin/mtg-linux-$(arch) ]]; then
         chmod +x bin/mtg-linux-$(arch) > /dev/null 2>&1
     fi
+    if [[ -f bin/tuic-server ]]; then
+        chmod +x bin/tuic-server > /dev/null 2>&1
+    fi
 
     echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
     local xui_script_temp="/usr/bin/x-ui-temp.$$"