21 Commity 0a2cd789ba ... 72df05a403

Autor SHA1 Wiadomość Data
  mrchatam 72df05a403 fix(hosts): keep TLS override fields visible when Security is same (#6452) 3 godzin temu
  mrchatam 503b5df4b9 fix(link): preserve Shadowsocks TLS query params on import (#6467) 3 godzin temu
  mrchatam bdd351bd15 fix(api): return 401 for invalid Bearer token instead of 404 (#6459) 3 godzin temu
  mrchatam 958d7f138e fix(frontend): fold sockopt v6only into V6Only on inbound load (#6453) 3 godzin temu
  Sanaei 6a159683d5 docs: update star history badges 3 godzin temu
  mrchatam b332d88438 feat(settings): add Block tab for JSON subscription routing rules (#6466) 3 godzin temu
  mrchatam 51e0afdd90 fix(inbounds): allow negative subSortIndex for subscription order (#6465) 3 godzin temu
  mrchatam b467d4c676 feat(reality): warn when target cert chain is too small for ML-DSA-65 (#6470) 3 godzin temu
  mrchatam 5fbd2b490c fix(clients): preserve enable on portable import (#6481) 3 godzin temu
  mrchatam 8082ab4d74 feat(clients): show short HWID fingerprint in admin device list (#6464) 3 godzin temu
  mrchatam 0a838563bb fix(clients): use EffectiveFlow in BulkAttach (#6454) 3 godzin temu
  Yuri Khachaturyan 3a93235783 docs(readme): add 3X-UI Manager to Community Tools (#6266) 3 godzin temu
  mrchatam 67addab343 fix(inbounds): serve fresh client UUIDs for list and allLinks (#6458) 3 godzin temu
  mrchatam 7a41c59494 fix(amneziawg): honor inbound listen when binding UDP socket (#6461) 3 godzin temu
  mrchatam 22763fe8f6 feat(clients): add Generate button for WireGuard/AmneziaWG PresharedKey (#6455) 3 godzin temu
  mrchatam 5cce2464f1 fix(clients): snap EOM 23:59:59 expiry to billing midnight without renew (#6457) 3 godzin temu
  mrchatam f51b0040cf fix(link): map vcn to verifyPeerCertByName in applySecurity (#6479) 3 godzin temu
  Sanaei dd46a06761 Update deps and fix AntD Space API 3 godzin temu
  Sanaei 19a692e074 fix(install): stop copying tuic-server over /usr/local/bin 15 godzin temu
  Sanaei 5815254fc3 fix(tuic): evict the oldest relay flow instead of refusing new clients 15 godzin temu
  amae 6d96accd63 Feature/tuic v5 (#6337) 4 godzin temu
100 zmienionych plików z 3133 dodań i 204 usunięć
  1. 25 0
      .github/workflows/release.yml
  2. 22 0
      DockerInit.sh
  3. 15 2
      README.ar_EG.md
  4. 15 2
      README.es_ES.md
  5. 15 2
      README.fa_IR.md
  6. 15 2
      README.md
  7. 15 2
      README.ru_RU.md
  8. 15 2
      README.tr_TR.md
  9. 15 2
      README.zh_CN.md
  10. 1 0
      docs/content/docs/en/config/inbounds.mdx
  11. 1 0
      docs/content/docs/en/config/meta.json
  12. 1 0
      docs/content/docs/en/config/share-links.mdx
  13. 113 0
      docs/content/docs/en/config/tuic.mdx
  14. 4 4
      docs/content/docs/en/reference/api/clients.mdx
  15. 1 0
      docs/content/docs/ru/config/inbounds.mdx
  16. 1 0
      docs/content/docs/ru/config/meta.json
  17. 1 0
      docs/content/docs/ru/config/share-links.mdx
  18. 112 0
      docs/content/docs/ru/config/tuic.mdx
  19. 95 5
      docs/public/openapi.json
  20. 20 20
      frontend/package-lock.json
  21. 4 4
      frontend/package.json
  22. 95 5
      frontend/public/openapi.json
  23. 8 0
      frontend/src/components/clients/ClientHwidList.tsx
  24. 22 0
      frontend/src/generated/examples.ts
  25. 89 3
      frontend/src/generated/schemas.ts
  26. 22 0
      frontend/src/generated/types.ts
  27. 26 2
      frontend/src/generated/zod.ts
  28. 1 0
      frontend/src/lib/clients/hwid-log.ts
  29. 2 0
      frontend/src/lib/hosts/host-link.ts
  30. 40 1
      frontend/src/lib/xray/inbound-defaults.ts
  31. 14 3
      frontend/src/lib/xray/inbound-form-adapter.ts
  32. 77 0
      frontend/src/lib/xray/inbound-link.ts
  33. 7 1
      frontend/src/lib/xray/inbound-tag.ts
  34. 6 0
      frontend/src/lib/xray/link-label.tsx
  35. 12 1
      frontend/src/lib/xray/outbound-link-parser.ts
  36. 3 1
      frontend/src/lib/xray/protocol-capabilities.ts
  37. 4 0
      frontend/src/models/dbinbound.ts
  38. 3 2
      frontend/src/pages/api-docs/endpoints.ts
  39. 1 0
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  40. 1 0
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  41. 17 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  42. 36 5
      frontend/src/pages/clients/ClientFormModal.tsx
  43. 1 0
      frontend/src/pages/clients/ClientInfoModal.tsx
  44. 36 2
      frontend/src/pages/clients/ClientQrModal.tsx
  45. 1 0
      frontend/src/pages/clients/ClientsPage.tsx
  46. 64 0
      frontend/src/pages/clients/tuicConfig.ts
  47. 2 2
      frontend/src/pages/hosts/HostFormModal.tsx
  48. 1 0
      frontend/src/pages/hosts/HostList.tsx
  49. 7 2
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  50. 1 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  51. 252 0
      frontend/src/pages/inbounds/form/protocols/tuic.tsx
  52. 28 1
      frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx
  53. 33 3
      frontend/src/pages/inbounds/form/security/reality.tsx
  54. 1 0
      frontend/src/pages/inbounds/info/helpers.ts
  55. 1 1
      frontend/src/pages/inbounds/list/InboundList.tsx
  56. 1 0
      frontend/src/pages/inbounds/list/helpers.ts
  57. 1 0
      frontend/src/pages/inbounds/list/types.ts
  58. 1 1
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  59. 1 0
      frontend/src/pages/inbounds/useInbounds.ts
  60. 1 1
      frontend/src/pages/settings/HappSettingsContent.tsx
  61. 98 46
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  62. 18 0
      frontend/src/schemas/client.ts
  63. 1 1
      frontend/src/schemas/forms/inbound-form.ts
  64. 2 0
      frontend/src/schemas/primitives/protocol.ts
  65. 3 0
      frontend/src/schemas/protocols/inbound/index.ts
  66. 60 0
      frontend/src/schemas/protocols/inbound/tuic.ts
  67. 1 0
      frontend/src/schemas/protocols/stream/external-proxy.ts
  68. 1 1
      frontend/src/test/client-form-modal.test.tsx
  69. 8 0
      frontend/src/test/host-link.test.ts
  70. 19 0
      frontend/src/test/inbound-defaults.test.ts
  71. 5 8
      frontend/src/test/inbound-form-adapter.test.ts
  72. 152 0
      frontend/src/test/inbound-link.test.ts
  73. 5 0
      frontend/src/test/inbound-tag.test.ts
  74. 10 0
      frontend/src/test/link-label.test.ts
  75. 26 0
      frontend/src/test/outbound-link-parser.test.ts
  76. 102 0
      frontend/src/test/reality-mldsa-cert-chain.test.tsx
  77. 63 0
      frontend/src/test/tuic-client-config.test.ts
  78. 10 10
      go.mod
  79. 22 22
      go.sum
  80. 33 1
      install.sh
  81. 1 0
      internal/amneziawg/instance.go
  82. 4 1
      internal/amneziawg/instance_test.go
  83. 5 2
      internal/amneziawg/types.go
  84. 2 1
      internal/amneziawgnet/client_device.go
  85. 2 1
      internal/amneziawgnet/device.go
  86. 4 4
      internal/amneziawgnet/manager.go
  87. 188 0
      internal/amneziawgnet/pinned_bind.go
  88. 160 0
      internal/amneziawgnet/pinned_bind_test.go
  89. 4 4
      internal/amneziawgnet/resolving_bind.go
  90. 9 4
      internal/amneziawgnet/resolving_bind_test.go
  91. 3 4
      internal/database/db.go
  92. 3 2
      internal/database/model/model.go
  93. 58 0
      internal/sub/clash_service.go
  94. 180 0
      internal/sub/export_all_links_test.go
  95. 1 1
      internal/sub/json_service.go
  96. 11 0
      internal/sub/json_service_test.go
  97. 38 0
      internal/sub/links_test.go
  98. 112 7
      internal/sub/service.go
  99. 190 0
      internal/sub/service_tuic_test.go
  100. 94 0
      internal/tuic/config.go

+ 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

+ 15 - 2
README.ar_EG.md

@@ -198,6 +198,7 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
 أدوات وتكاملات بناها المجتمع حول 3x-ui.
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (الترخيص: **MIT**): _عميل أندرويد أصلي لـ 3x-ui — لوحة التحكم، الاتصالات الواردة، العملاء مع مشاركة رمز QR، العقد وإدارة عدة لوحات. متاح على F-Droid._
 
 ## دعم المشروع
 
@@ -211,6 +212,18 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
    <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
 </a>
 
-## النجوم عبر الزمن
+## سجل النجوم
 
-[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

+ 15 - 2
README.es_ES.md

@@ -198,6 +198,7 @@ Las contribuciones son bienvenidas. Por favor, lee la [Guía de contribución](/
 Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (Licencia: **MIT**): _Cliente nativo de Android para 3x-ui — panel de control, inbounds, clientes con compartición por QR, nodos y gestión de múltiples paneles. Disponible en F-Droid._
 
 ## Apoyar el Proyecto
 
@@ -212,6 +213,18 @@ Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
    <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
 </a>
 
-## Estrellas a lo Largo del Tiempo
+## Historial de estrellas
 
-[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

+ 15 - 2
README.fa_IR.md

@@ -198,6 +198,7 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
 ابزارها و یکپارچه‌سازی‌هایی که توسط جامعه پیرامون 3x-ui ساخته شده‌اند.
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینت‌ها، تنظیمات پنل و پیکربندی Xray به‌صورت کد با Terraform / OpenTofu._
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (مجوز: **MIT**): _کلاینت بومی اندروید برای 3x-ui — داشبورد، اینباندها، کلاینت‌ها با اشتراک‌گذاری QR، نودها و مدیریت چند پنل. در F-Droid در دسترس است._
 
 ## پشتیبانی از پروژه
 
@@ -212,6 +213,18 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
    <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
 </a>
 
-## ستاره‌ها در طول زمان
+## تاریخچه ستاره‌ها
 
-[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

+ 15 - 2
README.md

@@ -198,6 +198,7 @@ Contributions are welcome. Please read the [Contributing Guide](/CONTRIBUTING.md
 Tools and integrations built by the community around 3x-ui.
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (License: **MIT**): _Native Android client for 3x-ui — dashboard, inbounds, clients with QR sharing, nodes and multi-panel management. Available on F-Droid._
 
 ## Support project
 
@@ -212,6 +213,18 @@ Tools and integrations built by the community around 3x-ui.
    <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
 </a>
 
-## Stargazers over Time
+## Star History
 
-[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

+ 15 - 2
README.ru_RU.md

@@ -198,6 +198,7 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
 Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (Лицензия: **MIT**): _Нативный Android-клиент для 3x-ui — дашборд, входящие, клиенты с QR, узлы и управление несколькими панелями. Доступен в F-Droid._
 
 ## Поддержка проекта
 
@@ -212,6 +213,18 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
    <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
 </a>
 
-## Звезды с течением времени
+## История звёзд
 
-[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

+ 15 - 2
README.tr_TR.md

@@ -198,6 +198,7 @@ Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull req
 3x-ui çevresindeki topluluk tarafından oluşturulmuş araçlar ve entegrasyonlar.
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Gelen bağlantılarnı, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak (as code) yönetin._
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (Lisans: **MIT**): _3x-ui için yerel Android istemcisi — kontrol paneli, gelen bağlantılar, QR ile paylaşımlı kullanıcılar, düğümler ve çoklu panel yönetimi. F-Droid'de mevcut._
 
 ## Projeyi Destekleyin
 
@@ -212,6 +213,18 @@ Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull req
    <img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
 </a>
 
-## Yıldız Tablosu
+## Yıldız Geçmişi
 
-[![Zaman içerisindeki yıldız sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

+ 15 - 2
README.zh_CN.md

@@ -198,6 +198,7 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
 社区围绕 3x-ui 构建的工具和集成。
 
 - [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
+- [3X-UI Manager](https://github.com/yukh975/3X-UI-Manager) (许可证: **MIT**): _3x-ui 的原生 Android 客户端 — 仪表板、入站、带二维码分享的客户端、节点以及多面板管理。可在 F-Droid 获取。_
 
 ## 支持项目
 
@@ -212,6 +213,18 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
    <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
 </a>
 
-## 随时间变化的星标数
+## 星标历史
 
-[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+<a href="https://www.star-history.com/?repos=mhsanaei%2F3x-ui&type=date&legend=top-left">
+ <picture>
+   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&theme=dark&legend=top-left" />
+   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mhsanaei/3x-ui&type=date&legend=top-left" />
+ </picture>
+</a>
+
+<p align="center">
+ <a href="https://www.star-history.com/mhsanaei/3x-ui">
+  <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=rank" /></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending&theme=dark" /><source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=MHSanaei/3x-ui&type=trending" /></picture>
+ </a>
+</p>

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

+ 4 - 4
docs/content/docs/en/reference/api/clients.mdx

@@ -227,8 +227,8 @@ _openapi:
       title: Reset the recorded IP list for a client.
       url: '#reset-the-recorded-ip-list-for-a-client'
     - depth: 2
-      title: List registered HWID devices for a client. Hashes are not exposed.
-      url: '#list-registered-hwid-devices-for-a-client-hashes-are-not-exposed'
+      title: List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed.
+      url: '#list-registered-hwid-devices-for-a-client-with-a-short-fingerprint-full-hashes-are-not-exposed'
     - depth: 2
       title: Clear all registered HWID devices for a client so new devices can
         register again.
@@ -473,8 +473,8 @@ _openapi:
         id: list-source-ips-that-have-connected-with-the-given-clients-credentials-returns-an-array-of-ip-timestamp-strings
       - content: Reset the recorded IP list for a client.
         id: reset-the-recorded-ip-list-for-a-client
-      - content: List registered HWID devices for a client. Hashes are not exposed.
-        id: list-registered-hwid-devices-for-a-client-hashes-are-not-exposed
+      - content: List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed.
+        id: list-registered-hwid-devices-for-a-client-with-a-short-fingerprint-full-hashes-are-not-exposed
       - content: Clear all registered HWID devices for a client so new devices can
           register again.
         id: clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again

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

+ 95 - 5
docs/public/openapi.json

@@ -2604,7 +2604,8 @@
               "tunnel",
               "tun",
               "mtproto",
-              "amneziawg"
+              "amneziawg",
+              "tuic"
             ],
             "example": "vless",
             "type": "string"
@@ -2629,9 +2630,8 @@
           "sniffing": {},
           "streamSettings": {},
           "subSortIndex": {
-            "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+            "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)",
             "example": 1,
-            "minimum": 1,
             "type": "integer"
           },
           "tag": {
@@ -2823,6 +2823,14 @@
             "example": true,
             "type": "boolean"
           },
+          "tuicServer": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/TuicServerSettings"
+              }
+            ],
+            "nullable": true
+          },
           "wgDns": {
             "type": "string"
           },
@@ -3701,6 +3709,11 @@
             "example": "h2",
             "type": "string"
           },
+          "certChainBytes": {
+            "description": "CertChainBytes is the sum of DER lengths of the presented peer chain.\nxray-core ML-DSA-65 REALITY needs >= 3500 bytes (constant lives in xray-core).",
+            "example": 3427,
+            "type": "integer"
+          },
           "certChainValid": {
             "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
             "example": true,
@@ -3783,6 +3796,7 @@
         },
         "required": [
           "alpn",
+          "certChainBytes",
           "certChainValid",
           "certIssuer",
           "certSubject",
@@ -4076,6 +4090,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 +4654,7 @@
                       "ssMethod": "",
                       "tag": "in-443-tcp",
                       "tlsFlowCapable": true,
+                      "tuicServer": null,
                       "wgDns": "",
                       "wgMtu": 0,
                       "wgPublicKey": ""
@@ -7158,6 +7245,7 @@
                   "success": true,
                   "obj": {
                     "alpn": "h2",
+                    "certChainBytes": 3427,
                     "certChainValid": true,
                     "certIssuer": "Google Trust Services",
                     "certSubject": "cloudflare.com",
@@ -7237,6 +7325,7 @@
                   "obj": [
                     {
                       "alpn": "h2",
+                      "certChainBytes": 3427,
                       "certChainValid": true,
                       "certIssuer": "Google Trust Services",
                       "certSubject": "cloudflare.com",
@@ -9522,7 +9611,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "List registered HWID devices for a client. Hashes are not exposed.",
+        "summary": "List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed.",
         "operationId": "post_panel_api_clients_hwids_email",
         "parameters": [
           {
@@ -9562,7 +9651,8 @@
                       "userAgent": "Happ/1.0",
                       "deviceOs": "android",
                       "osVersion": "15",
-                      "deviceModel": "Pixel 9"
+                      "deviceModel": "Pixel 9",
+                      "fingerprint": "6ad17c93e821"
                     }
                   ]
                 }

+ 20 - 20
frontend/package-lock.json

@@ -23,12 +23,12 @@
         "persian-calendar-suite": "^1.5.6",
         "react": "^19.3.0",
         "react-dom": "^19.3.0",
-        "react-hook-form": "^7.87.0",
+        "react-hook-form": "^7.88.0",
         "react-i18next": "^17.0.13",
         "react-router": "^8.3.1",
         "swagger-ui-react": "^5.32.15",
         "uplot": "^1.6.32",
-        "zod": "^4.6.1"
+        "zod": "^4.6.2"
       },
       "devDependencies": {
         "@storybook/addon-a11y": "^10.6.0",
@@ -45,7 +45,7 @@
         "@vitest/coverage-v8": "^5.0.0",
         "husky": "^9.1.7",
         "jsdom": "^30.0.1",
-        "lint-staged": "^17.5.0",
+        "lint-staged": "^17.5.1",
         "msw": "^2.15.0",
         "oxfmt": "0.67.0",
         "oxlint": "1.82.0",
@@ -53,7 +53,7 @@
         "playwright": "^1.63.0",
         "storybook": "^10.6.0",
         "typescript": "7.0.2",
-        "vite": "8.2.2",
+        "vite": "8.3.0",
         "vitest": "^5.0.0"
       },
       "engines": {
@@ -8246,9 +8246,9 @@
       }
     },
     "node_modules/lint-staged": {
-      "version": "17.5.0",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.5.0.tgz",
-      "integrity": "sha512-ah2qsNtvKP1+Ak4rAvEEIvcXDLjjbr/xmxCvlequxqEOFYk0qINcZcRxD3Ic8wRn7/oQ8+wC9s0DfDrdMVCRFg==",
+      "version": "17.5.1",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.5.1.tgz",
+      "integrity": "sha512-7EDuco1xnBMeVpvAbeMq1U5KXwJGLmY8q6l+Ye78r36C4mPc+Vg1Z2SK8gHyRTyvPro90A0q5/FAZ+Au23d6QQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -9263,9 +9263,9 @@
       }
     },
     "node_modules/react-hook-form": {
-      "version": "7.87.0",
-      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.87.0.tgz",
-      "integrity": "sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==",
+      "version": "7.88.0",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
+      "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
       "license": "MIT",
       "engines": {
         "node": ">=18.0.0"
@@ -10573,16 +10573,16 @@
       }
     },
     "node_modules/vite": {
-      "version": "8.2.2",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
-      "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
+      "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "lightningcss": "^1.33.0",
-        "picomatch": "^4.0.5",
-        "postcss": "^8.5.26",
-        "rolldown": "~1.2.4",
+        "picomatch": "^4.0.7",
+        "postcss": "^8.5.28",
+        "rolldown": "~1.2.6",
         "tinyglobby": "^0.2.17"
       },
       "bin": {
@@ -10599,7 +10599,7 @@
       },
       "peerDependencies": {
         "@types/node": "^20.19.0 || >=22.12.0",
-        "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+        "@vitejs/devtools": "^0.7.1",
         "esbuild": "^0.27.0 || ^0.28.0",
         "jiti": ">=1.21.0",
         "less": "^4.0.0",
@@ -11033,9 +11033,9 @@
       "license": "Unlicense"
     },
     "node_modules/zod": {
-      "version": "4.6.1",
-      "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz",
-      "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==",
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz",
+      "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==",
       "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"

+ 4 - 4
frontend/package.json

@@ -49,12 +49,12 @@
     "persian-calendar-suite": "^1.5.6",
     "react": "^19.3.0",
     "react-dom": "^19.3.0",
-    "react-hook-form": "^7.87.0",
+    "react-hook-form": "^7.88.0",
     "react-i18next": "^17.0.13",
     "react-router": "^8.3.1",
     "swagger-ui-react": "^5.32.15",
     "uplot": "^1.6.32",
-    "zod": "^4.6.1"
+    "zod": "^4.6.2"
   },
   "devDependencies": {
     "@storybook/addon-a11y": "^10.6.0",
@@ -71,7 +71,7 @@
     "@vitest/coverage-v8": "^5.0.0",
     "husky": "^9.1.7",
     "jsdom": "^30.0.1",
-    "lint-staged": "^17.5.0",
+    "lint-staged": "^17.5.1",
     "msw": "^2.15.0",
     "oxfmt": "0.67.0",
     "oxlint": "1.82.0",
@@ -79,7 +79,7 @@
     "playwright": "^1.63.0",
     "storybook": "^10.6.0",
     "typescript": "7.0.2",
-    "vite": "8.2.2",
+    "vite": "8.3.0",
     "vitest": "^5.0.0"
   },
   "overrides": {

+ 95 - 5
frontend/public/openapi.json

@@ -2604,7 +2604,8 @@
               "tunnel",
               "tun",
               "mtproto",
-              "amneziawg"
+              "amneziawg",
+              "tuic"
             ],
             "example": "vless",
             "type": "string"
@@ -2629,9 +2630,8 @@
           "sniffing": {},
           "streamSettings": {},
           "subSortIndex": {
-            "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+            "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)",
             "example": 1,
-            "minimum": 1,
             "type": "integer"
           },
           "tag": {
@@ -2823,6 +2823,14 @@
             "example": true,
             "type": "boolean"
           },
+          "tuicServer": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/TuicServerSettings"
+              }
+            ],
+            "nullable": true
+          },
           "wgDns": {
             "type": "string"
           },
@@ -3701,6 +3709,11 @@
             "example": "h2",
             "type": "string"
           },
+          "certChainBytes": {
+            "description": "CertChainBytes is the sum of DER lengths of the presented peer chain.\nxray-core ML-DSA-65 REALITY needs >= 3500 bytes (constant lives in xray-core).",
+            "example": 3427,
+            "type": "integer"
+          },
           "certChainValid": {
             "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
             "example": true,
@@ -3783,6 +3796,7 @@
         },
         "required": [
           "alpn",
+          "certChainBytes",
           "certChainValid",
           "certIssuer",
           "certSubject",
@@ -4076,6 +4090,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 +4654,7 @@
                       "ssMethod": "",
                       "tag": "in-443-tcp",
                       "tlsFlowCapable": true,
+                      "tuicServer": null,
                       "wgDns": "",
                       "wgMtu": 0,
                       "wgPublicKey": ""
@@ -7158,6 +7245,7 @@
                   "success": true,
                   "obj": {
                     "alpn": "h2",
+                    "certChainBytes": 3427,
                     "certChainValid": true,
                     "certIssuer": "Google Trust Services",
                     "certSubject": "cloudflare.com",
@@ -7237,6 +7325,7 @@
                   "obj": [
                     {
                       "alpn": "h2",
+                      "certChainBytes": 3427,
                       "certChainValid": true,
                       "certIssuer": "Google Trust Services",
                       "certSubject": "cloudflare.com",
@@ -9522,7 +9611,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "List registered HWID devices for a client. Hashes are not exposed.",
+        "summary": "List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed.",
         "operationId": "post_panel_api_clients_hwids_email",
         "parameters": [
           {
@@ -9562,7 +9651,8 @@
                       "userAgent": "Happ/1.0",
                       "deviceOs": "android",
                       "osVersion": "15",
-                      "deviceModel": "Pixel 9"
+                      "deviceModel": "Pixel 9",
+                      "fingerprint": "6ad17c93e821"
                     }
                   ]
                 }

+ 8 - 0
frontend/src/components/clients/ClientHwidList.tsx

@@ -101,6 +101,14 @@ export default function ClientHwidListModal({
                     </Typography.Text>
                   </>
                 )}
+                {entry.fingerprint && (
+                  <>
+                    <br />
+                    <Typography.Text type="secondary" code>
+                      {t('pages.clients.hwidFingerprint')}: {entry.fingerprint}
+                    </Typography.Text>
+                  </>
+                )}
               </div>
               <Popconfirm
                 title={t('pages.clients.deleteHwidConfirm')}

+ 22 - 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": ""
@@ -915,6 +916,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "RealityScanResult": {
     "alpn": "h2",
+    "certChainBytes": 3427,
     "certChainValid": true,
     "certIssuer": "Google Trust Services",
     "certSubject": "cloudflare.com",
@@ -1002,6 +1004,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": "",

+ 89 - 3
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"
@@ -2603,9 +2604,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "sniffing": {},
       "streamSettings": {},
       "subSortIndex": {
-        "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+        "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)",
         "example": 1,
-        "minimum": 1,
         "type": "integer"
       },
       "tag": {
@@ -2797,6 +2797,14 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": true,
         "type": "boolean"
       },
+      "tuicServer": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/TuicServerSettings"
+          }
+        ],
+        "nullable": true
+      },
       "wgDns": {
         "type": "string"
       },
@@ -3675,6 +3683,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "h2",
         "type": "string"
       },
+      "certChainBytes": {
+        "description": "CertChainBytes is the sum of DER lengths of the presented peer chain.\nxray-core ML-DSA-65 REALITY needs \u003e= 3500 bytes (constant lives in xray-core).",
+        "example": 3427,
+        "type": "integer"
+      },
       "certChainValid": {
         "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
         "example": true,
@@ -3757,6 +3770,7 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     "required": [
       "alpn",
+      "certChainBytes",
       "certChainValid",
       "certIssuer",
       "certSubject",
@@ -4050,6 +4064,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": {

+ 22 - 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;
@@ -834,6 +835,7 @@ export interface ProbeResultUI {
 
 export interface RealityScanResult {
   alpn: string;
+  certChainBytes: number;
   certChainValid: boolean;
   certIssuer: string;
   certSubject: string;
@@ -921,6 +923,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;

+ 26 - 2
frontend/src/generated/zod.ts

@@ -631,14 +631,14 @@ 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(),
   shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
   sniffing: z.unknown(),
   streamSettings: z.unknown(),
-  subSortIndex: z.number().int().min(1),
+  subSortIndex: z.number().int(),
   tag: z.string(),
   total: z.number().int(),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
@@ -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(),
@@ -891,6 +892,7 @@ export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
 
 export const RealityScanResultSchema = z.object({
   alpn: z.string(),
+  certChainBytes: z.number().int(),
   certChainValid: z.boolean(),
   certIssuer: z.string(),
   certSubject: z.string(),
@@ -983,6 +985,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(),

+ 1 - 0
frontend/src/lib/clients/hwid-log.ts

@@ -8,6 +8,7 @@ export type ClientHwidInfo = {
   deviceOs: string;
   osVersion: string;
   deviceModel: string;
+  fingerprint: string;
 };
 
 // normalizeClientHwids accepts the API payload and returns typed entries,

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

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

@@ -10,6 +10,7 @@ import {
   MtprotoClientSchema,
   ShadowsocksClientSchema,
   TrojanClientSchema,
+  TuicClientSchema,
   VlessClientSchema,
   VmessClientSchema,
   WireguardClientSchema,
@@ -183,9 +184,17 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     }
     const so = streamRecord.sockopt;
     if (so && typeof so === 'object' && !Array.isArray(so)) {
-      const parsed = SockoptStreamSettingsSchema.safeParse(so);
+      const raw = { ...(so as Record<string, unknown>) };
+      // Imported/API configs may use lowercase v6only; the form key is V6Only.
+      if ('v6only' in raw) {
+        if (!('V6Only' in raw)) raw.V6Only = Boolean(raw.v6only);
+        delete raw.v6only;
+      }
+      const parsed = SockoptStreamSettingsSchema.safeParse(raw);
       if (parsed.success) {
-        streamRecord.sockopt = { ...(so as Record<string, unknown>), ...parsed.data };
+        streamRecord.sockopt = { ...raw, ...parsed.data };
+      } else {
+        streamRecord.sockopt = raw;
       }
     }
   }
@@ -209,7 +218,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     nodeId: row.nodeId ?? null,
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
     shareAddr: row.shareAddr ?? '',
-    subSortIndex: Math.max(1, row.subSortIndex ?? 1),
+    subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
     disableFlow: row.disableFlow ?? false,
     protocol,
     settings,
@@ -265,6 +274,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 {

+ 12 - 1
frontend/src/lib/xray/outbound-link-parser.ts

@@ -547,6 +547,8 @@ export function parseShadowsocksLink(link: string): Raw | null {
   // Two link shapes coexist:
   //   modern:  ss://base64(method:password)@host:port#remark
   //   legacy:  ss://base64(method:password@host:port)#remark
+  // Query may carry Xray-native stream params (type/security/sni/alpn/fp)
+  // emitted by the SS share-link generator — preserve them like trojan/vless.
   // Try modern first; fall back to legacy decode of the whole userinfo+host.
   let userInfo: string;
   let host: string;
@@ -562,6 +564,7 @@ export function parseShadowsocksLink(link: string): Raw | null {
     }
   }
   const queryIndex = linkNoHash.indexOf('?');
+  const rawQuery = queryIndex >= 0 ? linkNoHash.slice(queryIndex + 1) : '';
   const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
   const atIndex = core.indexOf('@');
   if (atIndex >= 0) {
@@ -581,7 +584,7 @@ export function parseShadowsocksLink(link: string): Raw | null {
         userInfo = rawUserInfo;
       }
     }
-    const hostPort = core.slice(atIndex + 1);
+    const hostPort = core.slice(atIndex + 1).replace(/\/+$/, '');
     const colon = hostPort.lastIndexOf(':');
     if (colon < 0) return null;
     host = hostPort.slice(0, colon);
@@ -605,12 +608,20 @@ export function parseShadowsocksLink(link: string): Raw | null {
   const sep = userInfo.indexOf(':');
   const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
   const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
+  const params = new URLSearchParams(rawQuery);
+  const network = params.get('type') ?? 'tcp';
+  const security = (params.get('security') ?? 'none') as string;
+  const stream = buildStream(network, security);
+  applyTransportParams(stream, params);
+  applySecurityParams(stream, params);
+  applyFinalMaskParam(stream, params);
   return {
     protocol: 'shadowsocks',
     tag: remark,
     settings: {
       servers: [{ address: host, port, password, method }],
     },
+    streamSettings: stream,
   };
 }
 

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

+ 3 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -1483,10 +1483,11 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/hwids/:email',
-        summary: 'List registered HWID devices for a client. Hashes are not exposed.',
+        summary:
+          'List registered HWID devices for a client with a short fingerprint. Full hashes are not exposed.',
         params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "firstSeen": 1735000000000,\n      "lastSeen": 1735100000000,\n      "userAgent": "Happ/1.0",\n      "deviceOs": "android",\n      "osVersion": "15",\n      "deviceModel": "Pixel 9"\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "firstSeen": 1735000000000,\n      "lastSeen": 1735100000000,\n      "userAgent": "Happ/1.0",\n      "deviceOs": "android",\n      "osVersion": "15",\n      "deviceModel": "Pixel 9",\n      "fingerprint": "6ad17c93e821"\n    }\n  ]\n}',
       },
       {
         method: 'DELETE',

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

+ 36 - 5
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);
@@ -508,6 +522,10 @@ export default function ClientFormModal({
     methods.setValue('wgPublicKey', kp.publicKey);
   }
 
+  function regenerateWireguardPresharedKey() {
+    methods.setValue('wgPreSharedKey', Wireguard.keyToBase64(Wireguard.generatePresharedKey()));
+  }
+
   function regenerateMtprotoSecret() {
     methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
   }
@@ -668,6 +686,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 +876,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%' }} />
@@ -1232,16 +1255,24 @@ export default function ClientFormModal({
                           >
                             <Input disabled />
                           </FormField>
-                          <FormField
-                            name="wgPreSharedKey"
+                          <Form.Item
                             label={t(
                               showAmneziawg
                                 ? 'pages.clients.amneziaWgPreSharedKey'
                                 : 'pages.clients.wireguardPreSharedKey',
                             )}
                           >
-                            <Input />
-                          </FormField>
+                            <Space.Compact style={{ display: 'flex' }}>
+                              <FormField name="wgPreSharedKey" noStyle>
+                                <Input style={{ flex: 1 }} />
+                              </FormField>
+                              <Button
+                                aria-label={t('regenerate')}
+                                icon={<ReloadOutlined />}
+                                onClick={regenerateWireguardPresharedKey}
+                              />
+                            </Space.Compact>
+                          </Form.Item>
                           {showWireguard && showAmneziawg ? (
                             <>
                               <FormField

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

+ 2 - 2
frontend/src/pages/hosts/HostFormModal.tsx

@@ -91,8 +91,8 @@ export default function HostFormModal({
   const [loading, setLoading] = useState(false);
 
   const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
-  const showTls = security === 'tls' || security === 'reality';
-  const showTlsExtras = security === 'tls';
+  const showTls = security === 'tls' || security === 'reality' || security === 'same';
+  const showTlsExtras = security === 'tls' || security === 'same';
 
   // React resets this during render rather than in an effect so the modal's
   // first open frame already shows cleared fields.

+ 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[] {

+ 7 - 2
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;
@@ -695,7 +697,7 @@ export default function InboundFormModal({
           t('pages.inbounds.form.subSortIndexHelp'),
         )}
       >
-        <InputNumber min={1} />
+        <InputNumber />
       </FormField>
 
       {protocol === Protocols.VLESS && (
@@ -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} />
+    </>
+  );
+}

+ 28 - 1
frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx

@@ -5,11 +5,15 @@ import type { ColumnsType } from 'antd/es/table';
 
 import type { RealityScanResult } from '@/generated/types';
 
+// xray-core ML-DSA-65 REALITY min peer cert-chain size (not defined in this repo).
+export const MLDSA65_MIN_CERT_CHAIN_BYTES = 3500;
+
 interface RealityTargetScannerModalProps {
   open: boolean;
   onClose: () => void;
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
   onPick: (result: RealityScanResult) => void;
+  mldsa65Enabled?: boolean;
 }
 
 export default function RealityTargetScannerModal({
@@ -17,6 +21,7 @@ export default function RealityTargetScannerModal({
   onClose,
   scanRealityCandidates,
   onPick,
+  mldsa65Enabled = false,
 }: RealityTargetScannerModalProps) {
   const { t } = useTranslation();
   const [loading, setLoading] = useState(false);
@@ -126,6 +131,28 @@ export default function RealityTargetScannerModal({
           <Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
         ),
     },
+    {
+      title: t('pages.inbounds.form.scanCertChain'),
+      dataIndex: 'certChainBytes',
+      key: 'certChainBytes',
+      width: 100,
+      render: (bytes: number) => {
+        if (!bytes) return '—';
+        if (mldsa65Enabled && bytes < MLDSA65_MIN_CERT_CHAIN_BYTES) {
+          return (
+            <Tooltip
+              title={t('pages.inbounds.form.scanMldsaCertChainTooSmall', {
+                length: bytes,
+                min: MLDSA65_MIN_CERT_CHAIN_BYTES,
+              })}
+            >
+              <Tag color="warning">{bytes} B</Tag>
+            </Tooltip>
+          );
+        }
+        return `${bytes} B`;
+      },
+    },
     {
       title: t('pages.inbounds.form.scanLatency'),
       dataIndex: 'latencyMs',
@@ -165,7 +192,7 @@ export default function RealityTargetScannerModal({
         </Button>,
       ]}
       title={t('pages.inbounds.form.scanModalTitle')}
-      width={960}
+      width={1080}
     >
       <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>

+ 33 - 3
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react';
-import { useFormContext } from 'react-hook-form';
+import { useFormContext, useWatch } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import {
   Alert,
@@ -25,7 +25,9 @@ import {
   validateRealityTarget,
 } from '@/lib/xray/stream-wire-normalize';
 import type { RealityScanResult } from '@/generated/types';
-import RealityTargetScannerModal from './RealityTargetScannerModal';
+import RealityTargetScannerModal, {
+  MLDSA65_MIN_CERT_CHAIN_BYTES,
+} from './RealityTargetScannerModal';
 
 interface RealityFormProps {
   saving: boolean;
@@ -59,6 +61,18 @@ export default function RealityForm({
   const { t } = useTranslation();
   const { getFieldState, trigger } = useFormContext();
   const [scannerOpen, setScannerOpen] = useState(false);
+  const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' });
+  const mldsa65Verify = useWatch({
+    name: 'streamSettings.realitySettings.settings.mldsa65Verify',
+  });
+  const mldsa65Enabled =
+    (typeof mldsa65Seed === 'string' && mldsa65Seed.trim() !== '') ||
+    (typeof mldsa65Verify === 'string' && mldsa65Verify.trim() !== '');
+  const mldsaChainTooSmall =
+    !!scanResult &&
+    mldsa65Enabled &&
+    scanResult.certChainBytes > 0 &&
+    scanResult.certChainBytes < MLDSA65_MIN_CERT_CHAIN_BYTES;
   /*
    * An untrusted certificate (self-signed fronting service on the LAN) is still
    * worth reading, so subject/issuer stay visible and only the verdict is added.
@@ -130,7 +144,11 @@ export default function RealityForm({
       {scanResult && (
         <Form.Item label=" " colon={false}>
           <Alert
-            type={scanResult.feasible && !scanResult.privateTarget ? 'success' : 'warning'}
+            type={
+              scanResult.feasible && !scanResult.privateTarget && !mldsaChainTooSmall
+                ? 'success'
+                : 'warning'
+            }
             showIcon
             title={
               scanResult.feasible
@@ -139,6 +157,14 @@ export default function RealityForm({
             }
             description={
               <>
+                {mldsaChainTooSmall && (
+                  <div style={{ marginBottom: 8 }}>
+                    {t('pages.inbounds.form.scanMldsaCertChainTooSmall', {
+                      length: scanResult.certChainBytes,
+                      min: MLDSA65_MIN_CERT_CHAIN_BYTES,
+                    })}
+                  </div>
+                )}
                 {scanResult.privateTarget && (
                   <div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
                 )}
@@ -159,6 +185,9 @@ export default function RealityForm({
                       ? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm')
                       : '—'}
                   </Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanCertChain')}>
+                    {scanResult.certChainBytes > 0 ? `${scanResult.certChainBytes} B` : '—'}
+                  </Descriptions.Item>
                   <Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
                     {scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
                   </Descriptions.Item>
@@ -330,6 +359,7 @@ export default function RealityForm({
         onClose={() => setScannerOpen(false)}
         scanRealityCandidates={scanRealityCandidates}
         onPick={(r) => applyRealityScanResult(r, true)}
+        mldsa65Enabled={mldsa65Enabled}
       />
     </>
   );

+ 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 - 1
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -119,7 +119,7 @@ export default function InboundList({
   );
 
   const hasAnySubSortIndex = useMemo(
-    () => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
+    () => dbInbounds.some((i) => (i.subSortIndex ?? 1) !== 1),
     [dbInbounds],
   );
 

+ 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[]> {

+ 1 - 1
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -563,7 +563,7 @@ export default function HappSettingsContent({
         okText={t('pages.settings.subHappBuildDeeplink')}
         width={650}
       >
-        <Space direction="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
+        <Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
           <div>
             <div style={{ fontWeight: 600, marginBottom: 4 }}>
               {t('pages.settings.subHappDirectDomains')}

+ 98 - 46
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -8,6 +8,7 @@ import {
   RocketOutlined,
   SendOutlined,
   SettingOutlined,
+  StopOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { onNumber } from '@/utils/onNumber';
@@ -31,10 +32,17 @@ const DEFAULT_MUX = {
   xudpConcurrency: 16,
   xudpProxyUDP443: 'reject',
 };
-const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
+
+type SubJsonRule = { type: string; outboundTag: string; domain?: string[]; ip?: string[] };
+
+const DEFAULT_DIRECT_RULES: SubJsonRule[] = [
   { type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
   { type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
 ];
+const DEFAULT_BLOCK_RULES: SubJsonRule[] = [
+  { type: 'field', outboundTag: 'block', domain: ['geosite:category-ads-all'] },
+];
+const BLOCK_IP_RULE: SubJsonRule = { type: 'field', outboundTag: 'block', ip: [] };
 
 const directIPsOptions = [
   { label: 'Private IP', value: 'geoip:private' },
@@ -57,6 +65,10 @@ const directDomainsOptions = [
   { label: 'Meta', value: 'geosite:meta' },
   { label: 'Google', value: 'geosite:google' },
 ];
+const blockDomainsOptions = [
+  { label: 'Ads All', value: 'geosite:category-ads-all' },
+  { label: 'Adult +18', value: 'geosite:category-porn' },
+];
 
 function readJson<T>(raw: string, fallback: T): T {
   try {
@@ -67,6 +79,11 @@ function readJson<T>(raw: string, fallback: T): T {
   }
 }
 
+function readRules(raw: string): SubJsonRule[] {
+  const parsed = readJson<unknown>(raw, null);
+  return Array.isArray(parsed) ? (parsed as SubJsonRule[]) : [];
+}
+
 export default function SubscriptionFormatsTab({
   allSetting,
   updateSetting,
@@ -75,7 +92,6 @@ export default function SubscriptionFormatsTab({
   const { isMobile } = useMediaQuery();
 
   const muxEnabled = allSetting.subJsonMux !== '';
-  const directEnabled = allSetting.subJsonRules !== '';
 
   const muxObj = useMemo(
     () =>
@@ -92,57 +108,48 @@ export default function SubscriptionFormatsTab({
     updateSetting({ subJsonMux: JSON.stringify(next) });
   }
 
-  const ruleArray = useMemo(() => {
-    if (!directEnabled) return null;
-    return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
-  }, [allSetting.subJsonRules, directEnabled]);
-
-  const directIPs = useMemo(() => {
-    if (!ruleArray) return [];
-    const ipRule = ruleArray.find((r) => r.ip);
-    return ipRule?.ip ?? [];
-  }, [ruleArray]);
+  const ruleArray = useMemo(() => readRules(allSetting.subJsonRules), [allSetting.subJsonRules]);
+  const directEnabled = ruleArray.some((r) => r.outboundTag === 'direct');
+  const blockEnabled = ruleArray.some((r) => r.outboundTag === 'block');
 
-  const directDomains = useMemo(() => {
-    if (!ruleArray) return [];
-    const dRule = ruleArray.find((r) => r.domain);
-    return dRule?.domain ?? [];
-  }, [ruleArray]);
+  const ruleValues = (tag: string, key: 'ip' | 'domain') =>
+    ruleArray.find((r) => r.outboundTag === tag && r[key])?.[key] ?? [];
 
-  function setDirectEnabled(v: boolean) {
-    updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
+  function writeRules(rules: SubJsonRule[]) {
+    updateSetting({ subJsonRules: rules.length > 0 ? JSON.stringify(rules) : '' });
   }
 
-  function setDirectIPs(value: string[]) {
-    if (!ruleArray) return;
-    let rules = [...ruleArray];
-    if (value.length === 0) {
-      rules = rules.filter((r) => !r.ip);
-    } else {
-      let idx = rules.findIndex((r) => r.ip);
-      if (idx === -1) {
-        rules.push({ ...DEFAULT_RULES[1] });
-        idx = rules.length - 1;
-      }
-      rules[idx] = { ...rules[idx], ip: [...value] };
+  function setTagEnabled(tag: string, defaults: SubJsonRule[], enabled: boolean) {
+    const rest = ruleArray.filter((r) => r.outboundTag !== tag);
+    if (!enabled) {
+      // Turning off the last managed tag also drops foreign-tag leftovers so
+      // the panel still has a path back to an empty subJsonRules.
+      const hasManaged = rest.some((r) => r.outboundTag === 'direct' || r.outboundTag === 'block');
+      writeRules(hasManaged ? rest : []);
+      return;
     }
-    updateSetting({ subJsonRules: JSON.stringify(rules) });
+    // Prepend block defaults so ads match before direct; never re-sort the rest.
+    writeRules(tag === 'block' ? [...defaults, ...rest] : [...rest, ...defaults]);
   }
 
-  function setDirectDomains(value: string[]) {
-    if (!ruleArray) return;
+  function setRuleValues(
+    tag: string,
+    key: 'ip' | 'domain',
+    template: SubJsonRule,
+    value: string[],
+  ) {
     let rules = [...ruleArray];
     if (value.length === 0) {
-      rules = rules.filter((r) => !r.domain);
+      rules = rules.filter((r) => !(r.outboundTag === tag && r[key]));
     } else {
-      let idx = rules.findIndex((r) => r.domain);
-      if (idx === -1) {
-        rules.push({ ...DEFAULT_RULES[0] });
-        idx = rules.length - 1;
+      let index = rules.findIndex((r) => r.outboundTag === tag && r[key]);
+      if (index === -1) {
+        rules.push({ ...template });
+        index = rules.length - 1;
       }
-      rules[idx] = { ...rules[idx], domain: [...value] };
+      rules[index] = { ...rules[index], [key]: [...value] };
     }
-    updateSetting({ subJsonRules: JSON.stringify(rules) });
+    writeRules(rules);
   }
 
   return (
@@ -385,16 +392,19 @@ export default function SubscriptionFormatsTab({
                 title={t('pages.settings.direct')}
                 description={t('pages.settings.directDesc')}
               >
-                <Switch checked={directEnabled} onChange={setDirectEnabled} />
+                <Switch
+                  checked={directEnabled}
+                  onChange={(v) => setTagEnabled('direct', DEFAULT_DIRECT_RULES, v)}
+                />
               </SettingListItem>
               {directEnabled && (
                 <div className="format-settings">
                   <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
                     <Select
                       mode="tags"
-                      value={directIPs}
+                      value={ruleValues('direct', 'ip')}
                       style={{ width: '100%' }}
-                      onChange={setDirectIPs}
+                      onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
                       options={directIPsOptions}
                     />
                   </SettingListItem>
@@ -408,9 +418,11 @@ export default function SubscriptionFormatsTab({
                   >
                     <Select
                       mode="tags"
-                      value={directDomains}
+                      value={ruleValues('direct', 'domain')}
                       style={{ width: '100%' }}
-                      onChange={setDirectDomains}
+                      onChange={(v) =>
+                        setRuleValues('direct', 'domain', DEFAULT_DIRECT_RULES[0], v)
+                      }
                       options={directDomainsOptions}
                     />
                   </SettingListItem>
@@ -419,6 +431,46 @@ export default function SubscriptionFormatsTab({
             </>
           ),
         },
+        {
+          key: '5',
+          label: catTabLabel(<StopOutlined />, t('pages.settings.block'), isMobile),
+          children: (
+            <>
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.block')}
+                description={t('pages.settings.blockDesc')}
+              >
+                <Switch
+                  checked={blockEnabled}
+                  onChange={(v) => setTagEnabled('block', DEFAULT_BLOCK_RULES, v)}
+                />
+              </SettingListItem>
+              {blockEnabled && (
+                <div className="format-settings">
+                  <SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
+                    <Select
+                      mode="tags"
+                      value={ruleValues('block', 'domain')}
+                      style={{ width: '100%' }}
+                      onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
+                      options={blockDomainsOptions}
+                    />
+                  </SettingListItem>
+                  <SettingListItem paddings="small" title={t('pages.xray.blockips')}>
+                    <Select
+                      mode="tags"
+                      value={ruleValues('block', 'ip')}
+                      style={{ width: '100%' }}
+                      onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
+                      options={directIPsOptions}
+                    />
+                  </SettingListItem>
+                </div>
+              )}
+            </>
+          ),
+        },
       ]}
     />
   );

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

+ 1 - 1
frontend/src/schemas/forms/inbound-form.ts

@@ -80,7 +80,7 @@ export const InboundDbFieldsSchema = z.object({
   nodeId: z.number().int().nullable().optional(),
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),
   shareAddr: z.string().default(''),
-  subSortIndex: z.number().int().min(1).default(1),
+  subSortIndex: z.number().int().default(1),
   disableFlow: z.boolean().default(false),
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;

+ 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', () => {

+ 5 - 8
frontend/src/test/inbound-form-adapter.test.ts

@@ -329,10 +329,10 @@ describe('subSortIndex', () => {
     expect(values.subSortIndex).toBe(1);
   });
 
-  it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
+  it('rawInboundToFormValues preserves positives and negatives; maps 0/absent to 1', () => {
     expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
     expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
-    expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
+    expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(-10);
   });
 
   it('formValuesToWirePayload includes subSortIndex in the payload', () => {
@@ -348,18 +348,15 @@ describe('subSortIndex', () => {
     expect(replay.subSortIndex).toBe(42);
   });
 
-  it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
+  it('InboundDbFieldsSchema accepts integers including negatives and defaults to 1', () => {
     // Reject for the RIGHT reason: the issue must be about subSortIndex, not some
     // unrelated field — otherwise a schema that rejects everything would pass.
     const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 });
     expect(nonInt.success).toBe(false);
     if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex');
 
-    const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 });
-    expect(belowMin.success).toBe(false);
-    if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex');
-
-    // A valid integer >= 1 must pass (guards against a mutant rejecting all values).
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(true);
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: -1 }).success).toBe(true);
     expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true);
     expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
   });

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

+ 26 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -304,6 +304,32 @@ describe('parseShadowsocksLink', () => {
     expect(settings.servers[0].password).toBe('legacypw');
   });
 
+  it('preserves Xray TLS query params on import (round-trip)', () => {
+    const userinfo = Base64.encode('chacha20-ietf-poly1305:secretpass', true);
+    const link =
+      `ss://${userinfo}@example.com:443` +
+      '?alpn=h2%2Chttp%2F1.1&fp=firefox&security=tls&sni=example.com&type=tcp#user';
+    const out = parseShadowsocksLink(link);
+    expect(out?.protocol).toBe('shadowsocks');
+    expect(out?.tag).toBe('user');
+    const settings = out?.settings as {
+      servers: Array<{ address: string; port: number; method: string; password: string }>;
+    };
+    expect(settings.servers[0]).toMatchObject({
+      address: 'example.com',
+      port: 443,
+      method: 'chacha20-ietf-poly1305',
+      password: 'secretpass',
+    });
+    const stream = out?.streamSettings as Record<string, unknown>;
+    expect(stream.network).toBe('tcp');
+    expect(stream.security).toBe('tls');
+    const tls = stream.tlsSettings as Record<string, unknown>;
+    expect(tls.serverName).toBe('example.com');
+    expect(tls.fingerprint).toBe('firefox');
+    expect(tls.alpn).toEqual(['h2', 'http/1.1']);
+  });
+
   it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
     const method = 'aes-256-gcm';
     const password = '>>>';

+ 102 - 0
frontend/src/test/reality-mldsa-cert-chain.test.tsx

@@ -0,0 +1,102 @@
+import { describe, it, expect } from 'vitest';
+import { Form } from 'antd';
+import type { ReactNode } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { RealityForm } from '@/pages/inbounds/form/security';
+import RealityTargetScannerModal from '@/pages/inbounds/form/security/RealityTargetScannerModal';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+import type { RealityScanResult } from '@/generated/types';
+import { renderWithProviders } from './test-utils';
+
+const smallChain: RealityScanResult = {
+  alpn: 'h2',
+  certChainBytes: 3427,
+  certChainValid: true,
+  certIssuer: 'Google Trust Services',
+  certSubject: 'cloudflare.com',
+  certValid: true,
+  curveID: 'X25519',
+  feasible: true,
+  h2: true,
+  host: 'www.cloudflare.com',
+  ip: '104.16.124.96',
+  latencyMs: 180,
+  notAfter: '2026-08-01T00:00:00Z',
+  port: 443,
+  privateTarget: false,
+  reason: '',
+  serverNames: ['www.cloudflare.com'],
+  target: 'www.cloudflare.com:443',
+  tls13: true,
+  tlsVersion: '1.3',
+  x25519: true,
+};
+
+function FormHarness({
+  children,
+  defaultValues,
+}: {
+  children: ReactNode;
+  defaultValues?: Record<string, unknown>;
+}) {
+  const methods = useForm<InboundFormValues>({ defaultValues: defaultValues as never });
+  return (
+    <FormProvider {...methods}>
+      <Form>{children}</Form>
+    </FormProvider>
+  );
+}
+
+const noop = () => {};
+
+function renderRealityForm(
+  scanResult: RealityScanResult | null,
+  defaultValues?: Record<string, unknown>,
+) {
+  return renderWithProviders(
+    <FormHarness defaultValues={defaultValues}>
+      <RealityForm
+        saving={false}
+        scanning={false}
+        scanResult={scanResult}
+        scanRealityTarget={noop}
+        scanRealityCandidates={async () => []}
+        applyRealityScanResult={noop}
+        randomizeShortIds={noop}
+        randomizeSpiderX={noop}
+        genRealityKeypair={noop}
+        clearRealityKeypair={noop}
+        genMldsa65={noop}
+        clearMldsa65={noop}
+      />
+    </FormHarness>,
+  );
+}
+
+describe('ML-DSA-65 cert chain warning', () => {
+  it('warns on the inbound form when ML-DSA-65 is on and the scanned chain is under 3500 bytes', () => {
+    const { getByText } = renderRealityForm(smallChain, {
+      streamSettings: { realitySettings: { mldsa65Seed: 'seed' } },
+    });
+    expect(getByText(/below the 3500-byte minimum required for ML-DSA-65/)).toBeTruthy();
+  });
+
+  it('does not warn on the inbound form when ML-DSA-65 is off', () => {
+    const { queryByText } = renderRealityForm(smallChain);
+    expect(queryByText(/below the 3500-byte minimum required for ML-DSA-65/)).toBeNull();
+  });
+
+  it('tags scanner rows whose cert chain is too small for ML-DSA-65', async () => {
+    const { findByText } = renderWithProviders(
+      <RealityTargetScannerModal
+        open
+        onClose={noop}
+        scanRealityCandidates={async () => [smallChain]}
+        onPick={noop}
+        mldsa65Enabled
+      />,
+    );
+    expect(await findByText('3427 B')).toBeTruthy();
+  });
+});

+ 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"');
+  });
+});

+ 10 - 10
go.mod

@@ -27,10 +27,10 @@ require (
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5
 	go.uber.org/atomic v1.11.0
-	golang.org/x/crypto v0.56.0
-	golang.org/x/net v0.58.0
+	golang.org/x/crypto v0.57.0
+	golang.org/x/net v0.59.0
 	golang.org/x/sys v0.48.0
-	golang.org/x/text v0.41.0
+	golang.org/x/text v0.42.0
 	google.golang.org/grpc v1.83.2
 	google.golang.org/protobuf v1.36.12
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -43,10 +43,10 @@ require (
 
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
-	github.com/andybalholm/brotli v1.2.3 // indirect
+	github.com/andybalholm/brotli v1.2.4 // indirect
 	github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
-	github.com/bytedance/sonic v1.15.3 // indirect
+	github.com/bytedance/sonic v1.15.4 // indirect
 	github.com/bytedance/sonic/loader v0.5.2 // indirect
 	github.com/cloudflare/circl v1.6.5 // indirect
 	github.com/cloudwego/base64x v0.1.7 // indirect
@@ -82,7 +82,7 @@ require (
 	github.com/miekg/dns v1.1.73 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	github.com/molecule-man/go-brrr v1.0.1 // indirect
+	github.com/molecule-man/go-brrr v1.1.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
 	github.com/pion/dtls/v3 v3.1.8 // indirect
 	github.com/pion/logging v0.2.4 // indirect
@@ -104,17 +104,17 @@ require (
 	github.com/vishvananda/netlink v1.3.1 // indirect
 	github.com/vishvananda/netns v0.0.5 // indirect
 	github.com/wlynxg/anet v0.0.5 // indirect
-	github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 // indirect
+	github.com/xtls/reality v0.0.0-20260910011853-5dabb073f8e8 // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
-	go.mongodb.org/mongo-driver/v2 v2.9.0 // indirect
+	go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect
 	go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
 	golang.org/x/arch v0.31.0 // indirect
-	golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
+	golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect
 	golang.org/x/sync v0.23.0 // indirect
 	golang.org/x/time v0.16.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260911204522-f61a6ca850bd // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )

+ 22 - 22
go.sum

@@ -6,14 +6,14 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 h1:D8d8gGvwXcTxUIsE4z6F6vjy4/VZddu95vMNtOygh1c=
 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
-github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
-github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/andybalholm/brotli v1.2.4 h1:649LN6qF+/7JJYNN3Wdu4Dt5PS6eC/yP/Go3Bb0/AHI=
+github.com/andybalholm/brotli v1.2.4/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg=
 github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8=
 github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
 github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
-github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
-github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
+github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8=
+github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
 github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
 github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -150,8 +150,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o=
-github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
+github.com/molecule-man/go-brrr v1.1.0 h1:rLBGSXA393y+5ttCBLkW34CHESop5tahrOFxVv/XDEU=
+github.com/molecule-man/go-brrr v1.1.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
 github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88=
 github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
@@ -229,16 +229,16 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
 github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
 github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
-github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
-github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
+github.com/xtls/reality v0.0.0-20260910011853-5dabb073f8e8 h1:s+7gZKOEVRwJClsAJlAY6TIrPdUwzk5X4EVik/wanUs=
+github.com/xtls/reality v0.0.0-20260910011853-5dabb073f8e8/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk=
 github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI=
 github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
-go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
+go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ=
+go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
@@ -261,12 +261,12 @@ go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1
 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
 golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
 golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
-golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
-golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
-golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
-golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
-golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
-golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
+golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
 golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
@@ -277,12 +277,12 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
-golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
-golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
 golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
 golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
-golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
-golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
@@ -291,8 +291,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260911204522-f61a6ca850bd h1:29LZPNIJg+GEmJwZXruJJRwqy/wnWAYZXDtROToV/aQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260911204522-f61a6ca850bd/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
 google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
 google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
 google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=

+ 33 - 1
install.sh

@@ -367,6 +367,32 @@ install_acme() {
     return 0
 }
 
+install_tuic_server() {
+    local target_arch=""
+    case "$(arch)" in
+        amd64|x86_64) target_arch="x86_64-unknown-linux-musl" ;;
+        arm64|aarch64) target_arch="aarch64-unknown-linux-musl" ;;
+        armv7|armv7l) target_arch="armv7-unknown-linux-musleabihf" ;;
+        386|i386|i686) target_arch="i686-unknown-linux-musl" ;;
+        armv6|armv6l|armv5|armv5l|s390x)
+            echo -e "${yellow}tuic-server does not provide prebuilt binaries for $(arch); TUIC inbounds will be unavailable on this machine${plain}"
+            return 0
+            ;;
+        *) return 0 ;;
+    esac
+
+    local tuic_url="https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-${target_arch}"
+    echo -e "${green}Installing tuic-server (${target_arch})...${plain}"
+    mkdir -p "${xui_folder}/bin"
+    if curl -fLR --connect-timeout 15 --retry 3 -o "${xui_folder}/bin/tuic-server" "${tuic_url}" && [[ -s "${xui_folder}/bin/tuic-server" ]]; then
+        chmod +x "${xui_folder}/bin/tuic-server"
+        echo -e "${green}tuic-server installed successfully${plain}"
+    else
+        rm -f "${xui_folder}/bin/tuic-server"
+        echo -e "${yellow}Failed to download tuic-server (optional), skipping${plain}"
+    fi
+}
+
 setup_ssl_certificate() {
     local domain="$1"
     local server_ip="$2"
@@ -1570,6 +1596,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 +1661,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 +1684,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}")"

+ 1 - 0
internal/amneziawg/instance.go

@@ -63,6 +63,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		Tag:                   ib.Tag,
 		InterfaceName:         interfaceNameForID(ib.Id),
 		ListenPort:            ib.Port,
+		Listen:                ib.Listen,
 		PrivateKey:            server.PrivateKey,
 		PublicKey:             server.PublicKey,
 		Address:               addresses,

+ 4 - 1
internal/amneziawg/instance_test.go

@@ -32,7 +32,7 @@ func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
 		{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
 		{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil},                 // no address: skipped
 	})
-	ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
+	ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Listen: "203.0.113.10", Settings: settings}
 
 	inst, ok := InstanceFromInbound(ib)
 	if !ok {
@@ -41,6 +41,9 @@ func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
 	if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
 		t.Fatalf("instance identity not carried over: %+v", inst)
 	}
+	if inst.Listen != "203.0.113.10" {
+		t.Fatalf("Listen = %q, want inbound listen carried through", inst.Listen)
+	}
 	if inst.InterfaceName != "awg7" {
 		t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
 	}

+ 5 - 2
internal/amneziawg/types.go

@@ -75,8 +75,11 @@ type Instance struct {
 	Tag           string
 	InterfaceName string
 	ListenPort    int
-	PrivateKey    string
-	PublicKey     string
+	// Listen is an optional host bind address (e.g. "203.0.113.10").
+	// Empty/wildcard keeps dual-stack StdNetBind; a real IP pins the UDP socket.
+	Listen     string
+	PrivateKey string
+	PublicKey  string
 	// Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24".
 	// Carries both the IPv4 and (when enabled) IPv6 server address.
 	Address []string

+ 2 - 1
internal/amneziawgnet/client_device.go

@@ -114,7 +114,8 @@ func newUnconfiguredClientDevice(inst amneziawg.OutboundInstance, opts DeviceOpt
 	if logger == nil {
 		logger = device.NewLogger(device.LogLevelSilent, fmt.Sprintf("(awg-out %s) ", inst.Tag))
 	}
-	dev := device.NewDevice(tun, newResolvingBind(), logger)
+	bind := newResolvingBind("")
+	dev := device.NewDevice(tun, bind, logger)
 
 	return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
 }

+ 2 - 1
internal/amneziawgnet/device.go

@@ -134,7 +134,8 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device
 	if logger == nil {
 		logger = device.NewLogger(device.LogLevelSilent, "")
 	}
-	dev := device.NewDevice(tun, newResolvingBind(), logger)
+	bind := newResolvingBind(inst.Listen)
+	dev := device.NewDevice(tun, bind, logger)
 
 	return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
 }

+ 4 - 4
internal/amneziawgnet/manager.go

@@ -257,12 +257,12 @@ func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
 	}
 }
 
-// addressFingerprint captures what IpcSet can't change on a running Device,
-// fixed when the netstack is built: address, and the S4-derived effective MTU.
+// addressFingerprint captures Bind/netstack identity IpcSet cannot change.
 func addressFingerprint(inst amneziawg.Instance) string {
-	return fmt.Sprintf("%d|%s",
+	return fmt.Sprintf("%d|%s|%s",
 		amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4),
-		strings.Join(inst.Address, ","))
+		strings.Join(inst.Address, ","),
+		normalizedListenFP(inst.Listen))
 }
 
 // Reconcile brings every desired instance's embedded interface up to date

+ 188 - 0
internal/amneziawgnet/pinned_bind.go

@@ -0,0 +1,188 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/netip"
+	"strconv"
+	"strings"
+	"sync"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// pinnedBind opens its UDP socket on exactly one host address (#6367).
+// Empty/wildcard listen still uses StdNetBind via newListenBind.
+type pinnedBind struct {
+	mu   sync.Mutex
+	addr netip.Addr
+	conn *net.UDPConn
+}
+
+func newPinnedBind(addr netip.Addr) *pinnedBind {
+	return &pinnedBind{addr: addr.Unmap()}
+}
+
+func (b *pinnedBind) Open(uport uint16) ([]awgconn.ReceiveFunc, uint16, error) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	if b.conn != nil {
+		return nil, 0, awgconn.ErrBindAlreadyOpen
+	}
+
+	network := "udp4"
+	if b.addr.Is6() {
+		network = "udp6"
+	}
+	pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), network, net.JoinHostPort(b.addr.String(), strconv.Itoa(int(uport))))
+	if err != nil {
+		return nil, 0, err
+	}
+	uc, ok := pc.(*net.UDPConn)
+	if !ok {
+		pc.Close()
+		return nil, 0, fmt.Errorf("amneziawgnet: listen %s returned %T, want *net.UDPConn", network, pc)
+	}
+	laddr, ok := uc.LocalAddr().(*net.UDPAddr)
+	if !ok {
+		uc.Close()
+		return nil, 0, fmt.Errorf("amneziawgnet: unexpected local addr %T", uc.LocalAddr())
+	}
+	b.conn = uc
+	return []awgconn.ReceiveFunc{b.makeReceiveFunc(uc)}, uint16(laddr.Port), nil
+}
+
+func (b *pinnedBind) makeReceiveFunc(uc *net.UDPConn) awgconn.ReceiveFunc {
+	return func(bufs [][]byte, sizes []int, eps []awgconn.Endpoint) (int, error) {
+		n, addr, err := uc.ReadFromUDPAddrPort(bufs[0])
+		if err != nil {
+			return 0, err
+		}
+		sizes[0] = n
+		eps[0] = &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(addr.Addr().Unmap(), addr.Port())}
+		return 1, nil
+	}
+}
+
+func (b *pinnedBind) Close() error {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	if b.conn == nil {
+		return nil
+	}
+	err := b.conn.Close()
+	b.conn = nil
+	return err
+}
+
+// SetMark is a no-op: the panel never configures a WireGuard fwmark here.
+func (b *pinnedBind) SetMark(uint32) error { return nil }
+
+func (b *pinnedBind) Send(bufs [][]byte, ep awgconn.Endpoint) error {
+	std, ok := ep.(*awgconn.StdNetEndpoint)
+	if !ok {
+		return awgconn.ErrWrongEndpointType
+	}
+	b.mu.Lock()
+	uc := b.conn
+	b.mu.Unlock()
+	if uc == nil {
+		return net.ErrClosed
+	}
+	for _, buf := range bufs {
+		if _, err := uc.WriteToUDPAddrPort(buf, std.AddrPort); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (b *pinnedBind) ParseEndpoint(s string) (awgconn.Endpoint, error) {
+	ap, err := netip.ParseAddrPort(s)
+	if err != nil {
+		return nil, err
+	}
+	return &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port())}, nil
+}
+
+func (b *pinnedBind) BatchSize() int { return 1 }
+
+// isWildcardListen reports empty / dual-stack wildcard listen values.
+// Includes ::0 (isAnyListen) and [::] so AmneziaWG keeps dual-stack StdNetBind.
+func isWildcardListen(listen string) bool {
+	switch strings.TrimSpace(listen) {
+	case "", "0.0.0.0", "::", "::0", "[::]", "[::0]":
+		return true
+	default:
+		return false
+	}
+}
+
+// parseListenAddr returns a concrete host address to pin. ok is false for
+// wildcards and for values that are not a bare IP (previously inert for AWG).
+func parseListenAddr(listen string) (addr netip.Addr, ok bool) {
+	listen = strings.TrimSpace(listen)
+	if isWildcardListen(listen) {
+		return netip.Addr{}, false
+	}
+	// Bracketed IPv6 literal e.g. [::1] — strip for ParseAddr.
+	if strings.HasPrefix(listen, "[") && strings.HasSuffix(listen, "]") {
+		listen = listen[1 : len(listen)-1]
+	}
+	addr, err := netip.ParseAddr(listen)
+	if err != nil {
+		return netip.Addr{}, false
+	}
+	return addr.Unmap(), true
+}
+
+// listenBindable probes whether addr can be used as a UDP local address.
+func listenBindable(addr netip.Addr) bool {
+	network := "udp4"
+	if addr.Is6() {
+		network = "udp6"
+	}
+	pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), network, net.JoinHostPort(addr.String(), "0"))
+	if err != nil {
+		return false
+	}
+	_ = pc.Close()
+	return true
+}
+
+// newListenBind returns StdNetBind for wildcards / unusable listen values, or
+// a pinnedBind for a real local address. Never fails the inbound on bad listen.
+func newListenBind(listen string) awgconn.Bind {
+	raw := strings.TrimSpace(listen)
+	addr, pinned := parseListenAddr(raw)
+	if !pinned {
+		if raw != "" && !isWildcardListen(raw) {
+			logger.Warningf("amneziawgnet: listen %q is not a bindable IP; using dual-stack wildcard", raw)
+		}
+		return awgconn.NewDefaultBind()
+	}
+	if !listenBindable(addr) {
+		logger.Warningf("amneziawgnet: listen %q is not usable on this host; using dual-stack wildcard", raw)
+		return awgconn.NewDefaultBind()
+	}
+	return newPinnedBind(addr)
+}
+
+// normalizedListenFP collapses wildcard spellings so fingerprint rebuilds
+// only when the effective Bind actually changes.
+func normalizedListenFP(listen string) string {
+	if isWildcardListen(listen) {
+		return ""
+	}
+	addr, ok := parseListenAddr(listen)
+	if !ok {
+		return "" // unusable → same Bind as wildcard fallback
+	}
+	if !listenBindable(addr) {
+		return ""
+	}
+	return addr.String()
+}

+ 160 - 0
internal/amneziawgnet/pinned_bind_test.go

@@ -0,0 +1,160 @@
+package amneziawgnet
+
+import (
+	"net"
+	"net/netip"
+	"strconv"
+	"testing"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+)
+
+func TestParseListenAddr(t *testing.T) {
+	cases := []struct {
+		in     string
+		pinned bool
+		want   string
+	}{
+		{in: "", pinned: false},
+		{in: "  ", pinned: false},
+		{in: "0.0.0.0", pinned: false},
+		{in: "::", pinned: false},
+		{in: "::0", pinned: false},
+		{in: "[::]", pinned: false},
+		{in: "[::0]", pinned: false},
+		{in: "127.0.0.1", pinned: true, want: "127.0.0.1"},
+		{in: "::1", pinned: true, want: "::1"},
+		{in: "[::1]", pinned: true, want: "::1"},
+		{in: "not-an-ip", pinned: false},
+		{in: "/var/run/awg.sock", pinned: false},
+	}
+	for _, tc := range cases {
+		addr, ok := parseListenAddr(tc.in)
+		if ok != tc.pinned {
+			t.Fatalf("parseListenAddr(%q) pinned=%v, want %v", tc.in, ok, tc.pinned)
+		}
+		if tc.pinned && addr.String() != tc.want {
+			t.Fatalf("parseListenAddr(%q) = %s, want %s", tc.in, addr, tc.want)
+		}
+	}
+}
+
+func TestNewListenBindPinsSpecificAddress(t *testing.T) {
+	bind := newListenBind("127.0.0.1")
+	pb, ok := bind.(*pinnedBind)
+	if !ok {
+		t.Fatalf("bind type = %T, want *pinnedBind", bind)
+	}
+	fns, port, err := pb.Open(0)
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	defer pb.Close()
+	if len(fns) != 1 {
+		t.Fatalf("ReceiveFuncs = %d, want 1", len(fns))
+	}
+	if port == 0 {
+		t.Fatal("expected a concrete ephemeral port")
+	}
+
+	laddr := pb.conn.LocalAddr().(*net.UDPAddr)
+	got := laddr.AddrPort().Addr().Unmap()
+	if got.String() != "127.0.0.1" {
+		t.Fatalf("LocalAddr = %v, want 127.0.0.1", got)
+	}
+
+	clash := newListenBind("127.0.0.1")
+	if _, _, err := clash.Open(port); err == nil {
+		clash.Close()
+		t.Fatalf("Open(%d) unexpectedly succeeded on an already-bound address", port)
+	}
+}
+
+func TestNewListenBindWildcardUsesDefault(t *testing.T) {
+	for _, listen := range []string{"", "0.0.0.0", "::", "::0", "[::]", "hostname.example", "203.0.113.10", "not-an-ip"} {
+		bind := newListenBind(listen)
+		if _, ok := bind.(*pinnedBind); ok {
+			t.Fatalf("newListenBind(%q) returned pinnedBind, want default StdNetBind", listen)
+		}
+	}
+}
+
+func TestPinnedBindRoundTrip(t *testing.T) {
+	server := newListenBind("127.0.0.1")
+	recvFns, port, err := server.Open(0)
+	if err != nil {
+		t.Fatalf("server Open: %v", err)
+	}
+	defer server.Close()
+
+	client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+	if err != nil {
+		t.Fatalf("client listen: %v", err)
+	}
+	defer client.Close()
+
+	payload := []byte("hello-awg-listen")
+	dst := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port)))
+	ap, err := netip.ParseAddrPort(dst)
+	if err != nil {
+		t.Fatalf("ParseAddrPort: %v", err)
+	}
+	if _, err := client.WriteToUDPAddrPort(payload, ap); err != nil {
+		t.Fatalf("client write: %v", err)
+	}
+
+	bufs := [][]byte{make([]byte, 1500)}
+	sizes := make([]int, 1)
+	eps := make([]awgconn.Endpoint, 1)
+	n, err := recvFns[0](bufs, sizes, eps)
+	if err != nil {
+		t.Fatalf("receive: %v", err)
+	}
+	if n != 1 || sizes[0] != len(payload) {
+		t.Fatalf("receive n=%d size=%d, want 1/%d", n, sizes[0], len(payload))
+	}
+	if string(bufs[0][:sizes[0]]) != string(payload) {
+		t.Fatalf("payload = %q, want %q", bufs[0][:sizes[0]], payload)
+	}
+
+	reply := []byte("pong")
+	if err := server.Send([][]byte{reply}, eps[0]); err != nil {
+		t.Fatalf("Send: %v", err)
+	}
+	_ = client.SetReadDeadline(time.Now().Add(2 * time.Second))
+	buf := make([]byte, 1500)
+	rn, _, err := client.ReadFromUDP(buf)
+	if err != nil {
+		t.Fatalf("client read: %v", err)
+	}
+	if string(buf[:rn]) != string(reply) {
+		t.Fatalf("reply = %q, want %q", buf[:rn], reply)
+	}
+}
+
+func TestAddressFingerprintIncludesListen(t *testing.T) {
+	base := amneziawg.Instance{
+		MTU:         1420,
+		Address:     []string{"10.8.1.1/24"},
+		Obfuscation: amneziawg.Obfuscation31{},
+	}
+	a := addressFingerprint(base)
+	base.Listen = "127.0.0.1"
+	b := addressFingerprint(base)
+	if a == b {
+		t.Fatalf("listen edit did not change addressFingerprint: %q", a)
+	}
+	base.Listen = "0.0.0.0"
+	if addressFingerprint(base) != a {
+		t.Fatal("wildcard spellings must share the empty-listen fingerprint")
+	}
+	base.Listen = "hostname.example"
+	if addressFingerprint(base) != a {
+		t.Fatal("unusable listen must fingerprint like wildcard fallback")
+	}
+}
+
+var _ awgconn.Bind = (*pinnedBind)(nil)

+ 4 - 4
internal/amneziawgnet/resolving_bind.go

@@ -15,8 +15,8 @@ import (
 // endpointResolveTimeout bounds the one-time DNS lookup in ParseEndpoint.
 const endpointResolveTimeout = 5 * time.Second
 
-// resolvingBind lets peer endpoints be hostnames: StdNetBind has no DNS and
-// an unresolved name kills the whole IpcSet. Resolved once at configure.
+// resolvingBind wraps a Bind so peer endpoints may be hostnames (#6367).
+// Concrete listen values use pinnedBind; wildcards keep StdNetBind.
 type resolvingBind struct {
 	awgconn.Bind
 }
@@ -35,8 +35,8 @@ func defaultLookupEndpointHost(ctx context.Context, host string) ([]netip.Addr,
 	return out, nil
 }
 
-func newResolvingBind() *resolvingBind {
-	return &resolvingBind{Bind: awgconn.NewDefaultBind()}
+func newResolvingBind(listen string) *resolvingBind {
+	return &resolvingBind{Bind: newListenBind(listen)}
 }
 
 // ParseEndpoint resolves hostnames before handing the address to amneziawg-go

+ 9 - 4
internal/amneziawgnet/resolving_bind_test.go

@@ -9,6 +9,11 @@ import (
 	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 )
 
+func mustResolvingBind(t *testing.T) *resolvingBind {
+	t.Helper()
+	return newResolvingBind("")
+}
+
 func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort {
 	std, ok := ep.(*awgconn.StdNetEndpoint)
 	if !ok {
@@ -18,7 +23,7 @@ func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort {
 }
 
 func TestResolvingBind_ParseEndpointIPLiteral(t *testing.T) {
-	b := newResolvingBind()
+	b := mustResolvingBind(t)
 	ep, err := b.ParseEndpoint("203.0.113.7:51820")
 	if err != nil {
 		t.Fatalf("IP endpoint rejected: %v", err)
@@ -39,7 +44,7 @@ func TestResolvingBind_ParseEndpointHostnameResolves(t *testing.T) {
 	}
 	defer func() { lookupEndpointHost = orig }()
 
-	b := newResolvingBind()
+	b := mustResolvingBind(t)
 	ep, err := b.ParseEndpoint("peer.example.test:443")
 	if err != nil {
 		t.Fatalf("hostname endpoint rejected: %v", err)
@@ -56,14 +61,14 @@ func TestResolvingBind_ParseEndpointResolveFailureIsAnError(t *testing.T) {
 	}
 	defer func() { lookupEndpointHost = orig }()
 
-	b := newResolvingBind()
+	b := mustResolvingBind(t)
 	if _, err := b.ParseEndpoint("missing.example.test:80"); err == nil {
 		t.Fatal("expected resolve failure to surface as an error")
 	}
 }
 
 func TestResolvingBind_ParseEndpointBadPortRejected(t *testing.T) {
-	b := newResolvingBind()
+	b := mustResolvingBind(t)
 	if _, err := b.ParseEndpoint("203.0.113.7:none"); err == nil {
 		t.Fatal("expected bad port to be rejected")
 	}

+ 3 - 4
internal/database/db.go

@@ -1031,11 +1031,10 @@ func migrateTgIDIndex() error {
 	return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID")
 }
 
-// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
-// minimum (rows written by builds that defaulted the column to 0, or by nodes
-// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
+// normalizeInboundSubSortIndex lifts legacy zero defaults to 1.
+// Explicit negatives are left alone so primary inbounds can sort first.
 func normalizeInboundSubSortIndex() error {
-	res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
+	res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index = 0")
 	if res.Error != nil {
 		log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
 		return res.Error

+ 3 - 2
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.
@@ -51,7 +52,7 @@ type Inbound struct {
 	Down                 int64                `json:"down" form:"down"`                                                                                                                                             // Download traffic in bytes
 	Total                int64                `json:"total" form:"total"`                                                                                                                                           // Total traffic limit in bytes
 	Remark               string               `json:"remark" form:"remark" example:"VLESS-443"`                                                                                                                     // Human-readable remark
-	SubSortIndex         int                  `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"`                                                                     // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id)
+	SubSortIndex         int                  `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty" example:"1"`                                                                           // Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"`                                                                         // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
@@ -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.

+ 180 - 0
internal/sub/export_all_links_test.go

@@ -1,11 +1,14 @@
 package sub
 
 import (
+	"encoding/base64"
+	"net/url"
 	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 
 // inboundLinks (the "Export all inbound links" path) must render the remark
@@ -23,6 +26,16 @@ func TestInboundLinks_RemarkTemplateClientTokens(t *testing.T) {
 	if err := db.Create(ib).Error; err != nil {
 		t.Fatalf("seed inbound: %v", err)
 	}
+	client := &model.ClientRecord{
+		Email: "john@e", SubID: "subABC", UUID: "11111111-2222-4333-8444-000000000001",
+		Enable: true, Comment: "vip", TgID: 777,
+	}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
 
 	svc := NewSubService("{{INBOUND}}-{{EMAIL}}-{{COMMENT}}-{{SUB_ID}}-{{TELEGRAM_ID}}-{{SHORT_ID}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
 	svc.PrepareForRequest("req.example.com")
@@ -41,3 +54,170 @@ func TestInboundLinks_RemarkTemplateClientTokens(t *testing.T) {
 		t.Fatalf("display mode must drop the traffic/expiry segments: %s", frag)
 	}
 }
+
+// inboundLinks must use the clients-table UUID when the inbound settings JSON
+// still embeds a stale id (#6436).
+func TestInboundLinks_UsesClientsTableUUIDWhenSettingsStale(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	stale := "11111111-1111-1111-1111-111111111111"
+	fresh := "22222222-2222-2222-2222-222222222222"
+	settings := `{"clients":[{"id":"` + stale + `","email":"stale@e","subId":"subStale","enable":true}],"decryption":"none"}`
+	ib := &model.Inbound{
+		UserId: 1, Tag: "stale-uuid", Enable: true, Listen: "203.0.113.5", Port: 4432,
+		Protocol: model.VLESS, Remark: "Stale", Settings: settings,
+		StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: "stale@e", SubID: "subStale", UUID: fresh, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
+
+	svc := NewSubService("{{EMAIL}}")
+	svc.PrepareForRequest("req.example.com")
+	links := svc.inboundLinks(ib)
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	if !strings.Contains(links[0], fresh) {
+		t.Fatalf("link missing fresh UUID %q: %s", fresh, links[0])
+	}
+	if strings.Contains(links[0], stale) {
+		t.Fatalf("link still carries stale settings UUID %q: %s", stale, links[0])
+	}
+}
+
+// inboundLinks must still emit a VLESS link when clients exist only in the
+// inbound settings JSON (no clients / client_inbounds rows) — ListClientsForInbound
+// returns empty and clientsForLinkExport falls back to GetClients (#6458).
+func TestInboundLinks_SettingsOnlyVLESSProducesLink(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	uuid := "33333333-3333-3333-3333-333333333333"
+	settings := `{"clients":[{"id":"` + uuid + `","email":"settings@e","subId":"subSettings","enable":true}],"decryption":"none"}`
+	ib := &model.Inbound{
+		UserId: 1, Tag: "settings-only", Enable: true, Listen: "203.0.113.5", Port: 4434,
+		Protocol: model.VLESS, Remark: "SettingsOnly", Settings: settings,
+		StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+
+	svc := NewSubService("{{EMAIL}}")
+	svc.PrepareForRequest("req.example.com")
+	links := svc.inboundLinks(ib)
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	if !strings.Contains(links[0], "vless://") {
+		t.Fatalf("link = %q, want vless:// prefix", links[0])
+	}
+	if !strings.Contains(links[0], uuid) {
+		t.Fatalf("link missing settings UUID %q: %s", uuid, links[0])
+	}
+}
+
+// inboundLinks must keep each WireGuard/AmneziaWG inbound's own tunnel address
+// and private key when the same email is attached to both — those fields live
+// only in the per-inbound settings JSON, not the shared clients.wg_* columns.
+func TestInboundLinks_PreservesPerInboundWireGuardIdentity(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	wgPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("wg client keypair: %v", err)
+	}
+	awgPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("awg client keypair: %v", err)
+	}
+	// Shared clients row deliberately holds the *other* tunnel's key/address
+	// (last sync wins) — the failure mode ListClientsForInbound alone would export.
+	mergedPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("merged keypair: %v", err)
+	}
+
+	email := "dual@e"
+	wgSettings := `{"secretKey":"` + serverPriv + `","clients":[{"email":"` + email + `","privateKey":"` + wgPriv + `","allowedIPs":["10.0.0.5/32"],"enable":true}]}`
+	awgSettings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1420},` +
+		`"clients":[{"email":"` + email + `","privateKey":"` + awgPriv + `","allowedIPs":["10.8.1.5/32"],"enable":true}]}`
+
+	wgIb := &model.Inbound{
+		UserId: 1, Tag: "wg-dual", Enable: true, Listen: "203.0.113.7", Port: 51820,
+		Protocol: model.WireGuard, Remark: "WG", Settings: wgSettings,
+	}
+	awgIb := &model.Inbound{
+		UserId: 1, Tag: "awg-dual", Enable: true, Listen: "203.0.113.8", Port: 443,
+		Protocol: model.AmneziaWG, Remark: "AWG", Settings: awgSettings,
+	}
+	for _, ib := range []*model.Inbound{wgIb, awgIb} {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: "subDual", Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: "10.9.9.9/32",
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	for _, ib := range []*model.Inbound{wgIb, awgIb} {
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("create client_inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	svc := NewSubService("{{EMAIL}}")
+	svc.PrepareForRequest("req.example.com")
+
+	wgLinks := svc.inboundLinks(wgIb)
+	if len(wgLinks) != 1 {
+		t.Fatalf("wg links = %d, want 1: %v", len(wgLinks), wgLinks)
+	}
+	wu, err := url.Parse(wgLinks[0])
+	if err != nil {
+		t.Fatalf("wg link parse: %v (%s)", err, wgLinks[0])
+	}
+	if wu.User.Username() != wgPriv {
+		t.Fatalf("wg private key = %q, want inbound settings key %q (not merged %q)", wu.User.Username(), wgPriv, mergedPriv)
+	}
+	if got := wu.Query().Get("address"); got != "10.0.0.5/32" {
+		t.Fatalf("wg address = %q, want 10.0.0.5/32 (not merged 10.9.9.9/32)", got)
+	}
+
+	awgLinks := svc.inboundLinks(awgIb)
+	if len(awgLinks) != 1 {
+		t.Fatalf("awg links = %d, want 1: %v", len(awgLinks), awgLinks)
+	}
+	if !strings.HasPrefix(awgLinks[0], "vpn://") {
+		t.Fatalf("awg link = %q, want vpn:// prefix", awgLinks[0])
+	}
+	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(awgLinks[0], "vpn://"))
+	if err != nil {
+		t.Fatalf("awg link decode: %v (%s)", err, awgLinks[0])
+	}
+	textCfg := string(raw)
+	if !strings.Contains(textCfg, "PrivateKey = "+awgPriv) {
+		t.Fatalf("awg config missing inbound private key %q:\n%s", awgPriv, textCfg)
+	}
+	if !strings.Contains(textCfg, "Address = 10.8.1.5/32") {
+		t.Fatalf("awg config missing inbound address 10.8.1.5/32:\n%s", textCfg)
+	}
+	if strings.Contains(textCfg, mergedPriv) || strings.Contains(textCfg, "10.9.9.9/32") {
+		t.Fatalf("awg config leaked merged clients-table tunnel identity:\n%s", textCfg)
+	}
+}

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

+ 38 - 0
internal/sub/links_test.go

@@ -5,6 +5,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
@@ -59,3 +60,40 @@ func TestLinksForClient_UsesHostEndpoints(t *testing.T) {
 		t.Fatalf("link = %q, want the host endpoint proxy.example.com:443", links[0])
 	}
 }
+
+// LinksForClient (per-client QR / links API) must use the clients-table UUID
+// when the inbound settings JSON still embeds a stale id — same source as
+// /inbounds/list and allLinks (#6436).
+func TestLinksForClient_UsesClientsTableUUIDWhenSettingsStale(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	stale := "11111111-1111-1111-1111-111111111111"
+	fresh := "22222222-2222-2222-2222-222222222222"
+	settings := `{"clients":[{"id":"` + stale + `","email":"stale@e","subId":"subStale","enable":true}],"decryption":"none"}`
+	ib := &model.Inbound{
+		UserId: 1, Tag: "stale-uuid-qr", Enable: true, Listen: "203.0.113.5", Port: 4433,
+		Protocol: model.VLESS, Remark: "StaleQR", Settings: settings,
+		StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: "stale@e", SubID: "subStale", UUID: fresh, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
+
+	links := NewLinkProvider().LinksForClient("req.example.com", ib, "stale@e")
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	if !strings.Contains(links[0], fresh) {
+		t.Fatalf("link missing fresh UUID %q: %s", fresh, links[0])
+	}
+	if strings.Contains(links[0], stale) {
+		t.Fatalf("link still carries stale settings UUID %q: %s", stale, links[0])
+	}
+}

+ 112 - 7
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"
@@ -141,8 +142,10 @@ func (s *SubService) primeLinkClients(inboundId int, clients []model.Client, com
 }
 
 // clientForLink resolves one client of an inbound by email for link
-// generation: from the per-request cache when primed, otherwise by parsing
-// the settings JSON once and caching every client from it.
+// generation: from the per-request cache when primed, otherwise via
+// clientsForLinkExport (clients-table UUID identity with settings-JSON
+// fallback for share-link protocols; settings JSON for WireGuard/AmneziaWG
+// tunnel fields) and caches the list.
 func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.Client, bool) {
 	if m, ok := s.clientsByInbound[inbound.Id]; ok {
 		if c, hit := m[email]; hit {
@@ -152,7 +155,7 @@ func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.
 			return model.Client{}, false
 		}
 	}
-	clients, err := s.inboundService.GetClients(inbound)
+	clients, err := s.clientsForLinkExport(inbound)
 	if err != nil {
 		return model.Client{}, false
 	}
@@ -165,6 +168,29 @@ func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.
 	return model.Client{}, false
 }
 
+// clientsForLinkExport returns the clients used to build share / QR / allLinks
+// exports for one inbound. UUID-bearing protocols prefer the normalized clients
+// table so the link matches the running Xray identity when settings JSON is
+// stale (#6436). When that list is empty or unavailable (settings-only inbounds,
+// unsynced rows, unit tests without a DB), fall back to GetClients so links
+// still generate from the embedded settings JSON (#6458). WireGuard and
+// AmneziaWG always keep the inbound's own settings JSON: private key,
+// AllowedIPs, and related tunnel fields are deliberately per-inbound there,
+// while the shared clients.wg_* columns collapse to whichever tunnel inbound
+// synced last (see TunnelAllowedIPsByInbound / amneziaWGClientAddresses).
+func (s *SubService) clientsForLinkExport(inbound *model.Inbound) ([]model.Client, error) {
+	if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
+		return s.inboundService.GetClients(inbound)
+	}
+	if database.GetDB() != nil {
+		clients, err := s.inboundService.ListClientsForInbound(inbound.Id)
+		if err == nil && len(clients) > 0 {
+			return clients, nil
+		}
+	}
+	return s.inboundService.GetClients(inbound)
+}
+
 // linkSettings returns the inbound's settings decoded once per request with
 // the clients array left out — the link generators read only inbound-level
 // fields from it and resolve clients via clientForLink. The shallow
@@ -460,10 +486,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 // inboundLinks builds the share links for every distinct client of one inbound
 // the same way getSubs does — managed Host endpoints win over the plain link so
 // {{HOST}} and per-host variants render — but across all clients rather than a
-// single subId. Dedups duplicate client JSON entries by email (#5134). Backs the
-// panel's "Export all inbound links" so it matches the client/QR pages.
+// single subId. Resolves clients via clientsForLinkExport so UUID-bearing
+// protocols match the running Xray config (#6436) while WireGuard/AmneziaWG
+// keep per-inbound tunnel identity from settings. Dedups by email (#5134).
+// Backs the panel's "Export all inbound links" and matches client/QR pages.
 func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
-	clients, err := s.inboundService.GetClients(inbound)
+	clients, err := s.clientsForLinkExport(inbound)
 	if err != nil {
 		return nil
 	}
@@ -583,7 +611,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 +764,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
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików