6 Commits cfd4f64a79 ... 39ce7cbc22

Autor SHA1 Mensaje Fecha
  BlindMaster24 39ce7cbc22 fix(xray): migrate the dns outbound off its legacy nonIPQuery and blockTypes (#6519) hace 4 horas
  Jack c90996eda3 feat(sub): add opt-in month-end expiry presentation (#6517) hace 4 horas
  BlindMaster24 826e29e2de fix(xray): place the freedom domain strategy where the core reads it (#6515) hace 4 horas
  BlindMaster24 032ddcb29f fix(nodetoken): make the corrupt-ciphertext test corrupt deterministically (#6520) hace 4 horas
  Egor a09e136001 docs: add Discord bot to READMEs, architecture, operations guides, and locales (#6513) hace 4 horas
  Egor 2d7c8c77f7 docs: add TUIC v5 to READMEs, guides, and protocol references (#6511) hace 4 horas
Se han modificado 89 ficheros con 2229 adiciones y 196 borrados
  1. 3 2
      CLAUDE.md
  2. 3 2
      README.ar_EG.md
  3. 3 2
      README.es_ES.md
  4. 3 2
      README.fa_IR.md
  5. 3 2
      README.md
  6. 3 2
      README.ru_RU.md
  7. 3 2
      README.tr_TR.md
  8. 3 2
      README.zh_CN.md
  9. 1 1
      docs/README.md
  10. 41 31
      docs/architecture.md
  11. 4 4
      docs/content/docs/en/config/clients.mdx
  12. 27 2
      docs/content/docs/en/config/subscription.mdx
  13. 2 2
      docs/content/docs/en/guide/index.mdx
  14. 2 2
      docs/content/docs/en/index.mdx
  15. 2 6
      docs/content/docs/en/operations/backup-restore.mdx
  16. 1 1
      docs/content/docs/en/operations/multi-node.mdx
  17. 4 4
      docs/content/docs/fa/config/clients.mdx
  18. 2 0
      docs/content/docs/fa/config/inbounds.mdx
  19. 1 0
      docs/content/docs/fa/config/panel.mdx
  20. 2 3
      docs/content/docs/fa/config/subscription.mdx
  21. 2 2
      docs/content/docs/fa/guide/index.mdx
  22. 2 2
      docs/content/docs/fa/index.mdx
  23. 2 6
      docs/content/docs/fa/operations/backup-restore.mdx
  24. 121 0
      docs/content/docs/fa/operations/discord-bot.mdx
  25. 1 1
      docs/content/docs/fa/operations/multi-node.mdx
  26. 4 4
      docs/content/docs/ru/config/clients.mdx
  27. 1 0
      docs/content/docs/ru/config/inbounds.mdx
  28. 2 2
      docs/content/docs/ru/config/subscription.mdx
  29. 2 2
      docs/content/docs/ru/guide/index.mdx
  30. 2 2
      docs/content/docs/ru/index.mdx
  31. 3 8
      docs/content/docs/ru/operations/backup-restore.mdx
  32. 1 1
      docs/content/docs/ru/operations/multi-node.mdx
  33. 4 4
      docs/content/docs/zh/config/clients.mdx
  34. 2 0
      docs/content/docs/zh/config/inbounds.mdx
  35. 1 0
      docs/content/docs/zh/config/panel.mdx
  36. 1 1
      docs/content/docs/zh/config/subscription.mdx
  37. 2 2
      docs/content/docs/zh/guide/index.mdx
  38. 2 2
      docs/content/docs/zh/index.mdx
  39. 2 6
      docs/content/docs/zh/operations/backup-restore.mdx
  40. 121 0
      docs/content/docs/zh/operations/discord-bot.mdx
  41. 1 1
      docs/content/docs/zh/operations/multi-node.mdx
  42. 9 8
      docs/lib/site-i18n.ts
  43. 8 0
      docs/public/openapi.json
  44. 8 0
      frontend/public/openapi.json
  45. 2 0
      frontend/src/generated/examples.ts
  46. 8 0
      frontend/src/generated/schemas.ts
  47. 2 0
      frontend/src/generated/types.ts
  48. 2 0
      frontend/src/generated/zod.ts
  49. 50 10
      frontend/src/lib/xray/outbound-form-adapter.ts
  50. 1 0
      frontend/src/models/setting.ts
  51. 11 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  52. 3 24
      frontend/src/pages/xray/basics/BasicsTab.tsx
  53. 3 1
      frontend/src/pages/xray/basics/constants.ts
  54. 42 0
      frontend/src/pages/xray/basics/helpers.ts
  55. 15 8
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  56. 24 12
      frontend/src/pages/xray/outbounds/transport/sockopt.tsx
  57. 1 0
      frontend/src/schemas/setting.ts
  58. 98 0
      frontend/src/test/basics-freedom-strategy.test.ts
  59. 27 0
      frontend/src/test/calendar-expire-setting.test.tsx
  60. 154 0
      frontend/src/test/freedom-strategy-placement.test.ts
  61. 8 4
      frontend/src/test/outbound-form-adapter.test.ts
  62. 48 0
      frontend/src/test/outbound-form-modal.test.tsx
  63. 14 1
      internal/crypto/nodetoken/nodetoken_test.go
  64. 275 1
      internal/database/db.go
  65. 375 0
      internal/database/dns_outbound_legacy_keys_migration_test.go
  66. 295 0
      internal/database/freedom_domain_strategy_migration_test.go
  67. 21 0
      internal/sub/calendar_expire.go
  68. 241 0
      internal/sub/calendar_expire_test.go
  69. 1 1
      internal/sub/clash_service.go
  70. 1 1
      internal/sub/controller.go
  71. 1 1
      internal/sub/json_service.go
  72. 20 5
      internal/sub/service.go
  73. 1 0
      internal/web/entity/entity.go
  74. 0 1
      internal/web/service/config.json
  75. 37 0
      internal/web/service/default_template_freedom_test.go
  76. 5 0
      internal/web/service/setting.go
  77. 2 0
      internal/web/translation/ar-EG.json
  78. 2 0
      internal/web/translation/en-US.json
  79. 2 0
      internal/web/translation/es-ES.json
  80. 2 0
      internal/web/translation/fa-IR.json
  81. 2 0
      internal/web/translation/id-ID.json
  82. 2 0
      internal/web/translation/ja-JP.json
  83. 2 0
      internal/web/translation/pt-BR.json
  84. 2 0
      internal/web/translation/ru-RU.json
  85. 2 0
      internal/web/translation/tr-TR.json
  86. 2 0
      internal/web/translation/uk-UA.json
  87. 2 0
      internal/web/translation/vi-VN.json
  88. 2 0
      internal/web/translation/zh-CN.json
  89. 2 0
      internal/web/translation/zh-TW.json

+ 3 - 2
CLAUDE.md

@@ -41,6 +41,7 @@ file locations when it can answer in one hop.
 - `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
   category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
 - `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
+- `internal/tuic/` — TUIC v5 inbounds: `tuic-server` sidecar supervisor, native Go UDP relay traffic metering.
 - `internal/amneziawg/` — AmneziaWG protocol shape: instance/peer derivation
   from an inbound, 3.1 obfuscation param generation + validation, port-forward
   spec parsing.
@@ -57,8 +58,8 @@ file locations when it can answer in one hop.
 - `internal/web/` — Gin server (embeds `dist/` + `translation/`).
   - `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
   - `service/` — business logic (InboundService, SettingService, XrayService,
-    node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
-  - `job/` — 18 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
+    node sync); subpackages tgbot/, discord/, email/, outbound/, panel/, integration/.
+  - `job/` — 19 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
     CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
   - `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
     `runtime/` (master/sub-node over mTLS), `websocket/`.

+ 3 - 2
README.ar_EG.md

@@ -26,9 +26,10 @@
 
 ## الميزات
 
-- **اتصالات واردة متعددة البروتوكولات** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، AmneziaWG، Hysteria2، MTProto، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
+- **اتصالات واردة متعددة البروتوكولات** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، AmneziaWG، TUIC v5، Hysteria2، MTProto، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
 - **وسائل نقل وأمان حديثة** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، مؤمَّنة بـ TLS و XTLS و REALITY.
 - **AmneziaWG مدمج** — نسخة WireGuard المقاومة للفحص العميق للحزم (DPI) تعمل داخل اللوحة على مكدس شبكة في فضاء المستخدم، دون وحدة نواة أو DKMS أو حزم إضافية.
+- **TUIC v5 مدمج** — بروكسي عالي الأداء يعتمد على QUIC مع قياس حركة المرور عبر مرحل UDP أصلي، ومصافحات 0-RTT، والتحكم في الازدحام BBR.
 - **وكلاء MTProto** — أسرار FakeTLS وعلامات الإعلانات والحصص لكل عميل، تُطبَّق مباشرةً دون قطع الاتصالات القائمة.
 - **Fallback** — تقديم عدة بروتوكولات على منفذ واحد (مثل VLESS و Trojan على المنفذ 443) باستخدام ميزة fallback في Xray.
 - **إدارة لكل عميل** — حصص الترافيك، تواريخ انتهاء الصلاحية، حدود IP مع استثناء العناوين الموثوقة، حدود الأجهزة (HWID)، دورات تجديد مجدولة، حالة الاتصال المباشرة، وروابط مشاركة وأكواد QR واشتراكات بنقرة واحدة.
@@ -36,7 +37,7 @@
 - **دعم العقد المتعددة** — إدارة وتوسيع عبر عدة خوادم من لوحة واحدة، بما في ذلك استنساخ الاتصالات الواردة على عقد أخرى.
 - **الاتصالات الصادرة والتوجيه** — WARP، NordVPN، PIA، قواعد توجيه مخصصة، موازنات تحميل مع تجاوز الفشل بين الموازنات، وتسلسل الوكلاء الصادرة. ويمكن تصفّح فئات geosite و geoip المضمّنة مباشرةً من محرر القواعد.
 - **خادم اشتراك مدمج** — إخراج raw و JSON و Clash يُختار تلقائيًا حسب User-Agent الخاص بالعميل، مع [قوالب صفحات مخصصة](docs/custom-subscription-templates.md).
-- **روبوت تيليجرام** للمراقبة والإدارة عن بُعد.
+- **روبوتات تيليجرام وديسكورد** للمراقبة والإدارة عن بُعد.
 - **واجهة RESTful API** مع رموز وصول محدودة النطاق وقابلة لانتهاء الصلاحية، ومرجع API داخل اللوحة.
 - **لوحة قابلة للتثبيت (PWA)** — ثبّت 3X-UI على سطح المكتب أو شاشة هاتفك الرئيسية.
 - **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.

+ 3 - 2
README.es_ES.md

@@ -26,9 +26,10 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
 
 ## Características
 
-- **Entradas multiprotocolo** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel y TUN.
+- **Entradas multiprotocolo** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel y TUN.
 - **Transportes y seguridad modernos** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade y XHTTP, protegidos con TLS, XTLS y REALITY.
 - **AmneziaWG integrado** — WireGuard resistente al DPI se ejecuta dentro del panel sobre una pila de red en espacio de usuario, sin módulo del kernel, DKMS ni paquetes adicionales que instalar.
+- **TUIC v5 integrado** — Proxy de alto rendimiento basado en QUIC con medición de tráfico mediante retransmisión UDP nativa, handshakes 0-RTT y control de congestión BBR.
 - **Proxies MTProto** — secretos FakeTLS, ad-tags y cuotas por cliente, aplicados en caliente sin cortar las conexiones existentes.
 - **Fallbacks** — sirve varios protocolos en un solo puerto (p. ej. VLESS y Trojan en el 443) usando la función de fallback de Xray.
 - **Gestión por cliente** — cuotas de tráfico, fechas de caducidad, límites de IP con exenciones para direcciones de confianza, límites de dispositivos (HWID), ciclos de renovación programados, estado en línea en tiempo real y enlaces de compartición, códigos QR y suscripciones con un solo clic.
@@ -36,7 +37,7 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
 - **Soporte multinodo** — gestiona y escala a través de varios servidores desde un único panel, incluida la clonación de entradas en otros nodos.
 - **Salida y enrutamiento** — WARP, NordVPN, PIA, reglas de enrutamiento personalizadas, balanceadores de carga con conmutación por error entre balanceadores y encadenamiento de proxy de salida. Las categorías geosite y geoip incluidas se pueden explorar directamente desde el editor de reglas.
 - **Servidor de suscripción integrado** — salida raw, JSON y Clash, seleccionada automáticamente según el User-Agent del cliente, además de [plantillas de página personalizables](docs/custom-subscription-templates.md).
-- **Bot de Telegram** para monitorización y gestión remotas.
+- **Bots de Telegram y Discord** para monitorización y gestión remotas.
 - **API RESTful** con tokens de alcance limitado y caducidad opcional, y una referencia de la API dentro del panel.
 - **Panel instalable (PWA)** — ancla 3X-UI al escritorio o a la pantalla de inicio del móvil.
 - **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.

+ 3 - 2
README.fa_IR.md

@@ -26,9 +26,10 @@
 
 ## ویژگی‌ها
 
-- **اینباندهای چندپروتکلی** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، AmneziaWG، Hysteria2، MTProto، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
+- **اینباندهای چندپروتکلی** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، AmneziaWG، TUIC v5، Hysteria2، MTProto، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
 - **ترنسپورت‌ها و امنیت مدرن** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، ایمن‌شده با TLS، XTLS و REALITY.
 - **‏AmneziaWG داخلی** — نسخه‌ی مقاوم در برابر DPI از WireGuard مستقیماً درون پنل و روی یک پشته‌ی شبکه‌ی فضای کاربر اجرا می‌شود؛ بدون ماژول کرنل، DKMS یا بسته‌های اضافی.
+- **‏TUIC v5 داخلی** — پراکسی با کارایی بالا مبتنی بر QUIC با اندازه‌گیری بومی ترافیک رله UDP، دست‌دادن‌های 0-RTT و کنترل ازدحام BBR.
 - **پراکسی‌های MTProto** — سکرت‌های FakeTLS، ad-tag و سهمیه‌ها به‌ازای هر کلاینت، که به‌صورت زنده و بدون قطع اتصال‌های موجود اعمال می‌شوند.
 - **فال‌بک (Fallback)** — ارائه‌ی چند پروتکل روی یک پورت واحد (مثلاً VLESS و Trojan روی پورت 443) با استفاده از قابلیت fallback در Xray.
 - **مدیریت به‌ازای هر کلاینت** — سهمیه‌ی ترافیک، تاریخ انقضا، محدودیت IP با امکان استثنا کردن آدرس‌های مورد اعتماد، محدودیت دستگاه (HWID)، چرخه‌های تمدید زمان‌بندی‌شده، وضعیت آنلاینِ زنده و لینک‌های اشتراک‌گذاری، کدهای QR و سابسکریپشن‌ها با یک کلیک.
@@ -36,7 +37,7 @@
 - **پشتیبانی از چند نود** — مدیریت و مقیاس‌دهی روی چندین سرور از یک پنل واحد، از جمله کلون‌کردن اینباندها روی نودهای دیگر.
 - **اوتباند و مسیریابی** — WARP، NordVPN، PIA، قوانین مسیریابی سفارشی، متعادل‌کننده‌های بار (load balancer) با فال‌بک بین متعادل‌کننده‌ها و زنجیره‌کردن پراکسی اوتباند. دسته‌بندی‌های geosite و geoip همراه‌شده مستقیماً از ویرایشگر قوانین قابل مرور هستند.
 - **سرور سابسکریپشن داخلی** — خروجی raw، JSON و Clash که بر پایه‌ی User-Agent کلاینت به‌صورت خودکار انتخاب می‌شود، به‌همراه [قالب‌های صفحه‌ی سفارشی](docs/custom-subscription-templates.md).
-- **ربات تلگرام** برای نظارت و مدیریت از راه دور.
+- **ربات‌های تلگرام و دیسکورد** برای نظارت و مدیریت از راه دور.
 - **‏RESTful API** با توکن‌های محدودشده (scoped) و دارای انقضای اختیاری، به‌همراه مرجع API درون‌پنل.
 - **پنل قابل نصب (PWA)** — 3X-UI را به دسکتاپ یا صفحه‌ی اصلی گوشی خود سنجاق کنید.
 - **ذخیره‌سازی منعطف** — SQLite (پیش‌فرض) یا PostgreSQL.

+ 3 - 2
README.md

@@ -26,9 +26,10 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
 
 ## Features
 
-- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
+- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
 - **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
 - **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install.
+- **TUIC v5 sidecar** — High-performance QUIC-based proxy with native UDP relay traffic metering, 0-RTT handshakes, and BBR congestion control.
 - **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections.
 - **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
 - **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions.
@@ -36,7 +37,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
 - **Multi-node support** — manage and scale across multiple servers from a single panel, including cloning inbounds onto other nodes.
 - **Outbound & routing** — WARP, NordVPN, PIA, custom routing rules, load balancers with balancer-to-balancer fallback, and outbound proxy chaining. Bundled geosite and geoip categories are browsable straight from the rule editor.
 - **Built-in subscription server** — raw, JSON, and Clash output, auto-selected from the client's User-Agent, plus [custom page templates](docs/custom-subscription-templates.md).
-- **Telegram bot** for remote monitoring and management.
+- **Telegram and Discord bots** for remote monitoring and management.
 - **RESTful API** with scoped, optionally expiring tokens and an in-panel API reference.
 - **Installable panel (PWA)** — pin 3X-UI to a desktop or phone home screen.
 - **Flexible storage** — SQLite (default) or PostgreSQL.

+ 3 - 2
README.ru_RU.md

@@ -26,9 +26,10 @@
 
 ## Возможности
 
-- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
+- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
 - **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
 - **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов.
+- **Встроенный TUIC v5** — высокопроизводительный прокси на базе QUIC с нативным учётом трафика через UDP-релей, 0-RTT рукопожатиями и контролем перегрузок BBR.
 - **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения.
 - **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
 - **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
@@ -36,7 +37,7 @@
 - **Поддержка нескольких узлов** — управление и масштабирование на несколько серверов из одной панели, включая клонирование входящих на другие узлы.
 - **Исходящие подключения и маршрутизация** — WARP, NordVPN, PIA, пользовательские правила маршрутизации, балансировщики нагрузки с переключением между балансировщиками и цепочки исходящих прокси. Встроенные категории geosite и geoip можно просматривать прямо в редакторе правил.
 - **Встроенный сервер подписок** — вывод в форматах raw, JSON и Clash, выбираемый автоматически по User-Agent клиента, а также [пользовательские шаблоны страниц](docs/custom-subscription-templates.md).
-- **Telegram-бот** для удалённого мониторинга и управления.
+- **Telegram- и Discord-боты** для удалённого мониторинга и управления.
 - **RESTful API** с токенами ограниченной области действия и необязательным сроком действия, а также справочником API внутри панели.
 - **Устанавливаемая панель (PWA)** — закрепите 3X-UI на рабочем столе или главном экране телефона.
 - **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.

+ 3 - 2
README.tr_TR.md

@@ -26,9 +26,10 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
 
 ## Özellikler
 
-- **Çoklu protokol destekli gelen bağlantılar (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, Hysteria2, MTProto, HTTP, SOCKS (Karma), Dokodemo-door / Tunnel ve TUN.
+- **Çoklu protokol destekli gelen bağlantılar (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Karma), Dokodemo-door / Tunnel ve TUN.
 - **Modern aktarımlar (transports) ve güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvene alınmıştır.
 - **Dahili AmneziaWG** — DPI'ya dayanıklı WireGuard, panelin içinde bir kullanıcı alanı ağ yığını üzerinde çalışır; çekirdek modülü, DKMS veya ek paket kurulumu gerektirmez.
+- **Dahili TUIC v5** — Yerel UDP geçişi trafik ölçümü, 0-RTT el sıkışmaları ve BBR tıkanıklık kontrolü ile QUIC tabanlı yüksek performanslı proxy.
 - **MTProto proxy'leri** — İstemci başına FakeTLS gizli anahtarları, reklam etiketleri (ad-tag) ve kotalar, mevcut bağlantılar kopmadan anlık olarak uygulanır.
 - **Geri Dönüş (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port üzerinde birden fazla protokole (ör. 443 üzerinde hem VLESS hem Trojan) hizmet verin.
 - **Kullanıcı başına yönetim** — Trafik kotaları, bitiş tarihleri, güvenilir adreslere muafiyet tanınabilen IP sınırları, HWID cihaz sınırları, zamanlanmış yenileme döngüleri, canlı çevrimiçi (online) durumu ve tek tıkla paylaşım bağlantıları, QR kodları ve abonelikler.
@@ -36,7 +37,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
 - **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden birden fazla sunucuyu yönetin ve ölçeklendirin; gelen bağlantıları diğer düğümlere klonlayın.
 - **Giden bağlantı (Outbound) ve yönlendirme** — WARP, NordVPN, PIA, özel yönlendirme kuralları, dengeleyiciler arası yük devretme destekli yük dengeleyiciler (load balancers) ve giden bağlantı proxy zincirleme (proxy chaining). Pakete dahil geosite ve geoip kategorileri doğrudan kural düzenleyicisinden taranabilir.
 - **Dahili abonelik sunucusu** — İstemcinin User-Agent bilgisine göre otomatik seçilen raw, JSON ve Clash çıktısı ve [özel sayfa şablonları](docs/custom-subscription-templates.md).
-- Uzaktan izleme ve yönetim için **Telegram botu**.
+- Uzaktan izleme ve yönetim için **Telegram ve Discord botları**.
 - Kapsamı sınırlanmış, isteğe bağlı olarak süresi dolan token'lar ve panel içi API referansı sunan **RESTful API**.
 - **Kurulabilir panel (PWA)** — 3X-UI'yi masaüstüne veya telefon ana ekranına sabitleyin.
 - **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.

+ 3 - 2
README.zh_CN.md

@@ -26,9 +26,10 @@
 
 ## 功能特性
 
-- **多协议入站** — VLESS、VMess、Trojan、Shadowsocks、WireGuard、AmneziaWG、Hysteria2、MTProto、HTTP、SOCKS (Mixed)、Dokodemo-door / Tunnel 和 TUN。
+- **多协议入站** — VLESS、VMess、Trojan、Shadowsocks、WireGuard、AmneziaWG、TUIC v5、Hysteria2、MTProto、HTTP、SOCKS (Mixed)、Dokodemo-door / Tunnel 和 TUN。
 - **现代传输与安全** — TCP (Raw)、mKCP、WebSocket、gRPC、HTTPUpgrade 和 XHTTP,并通过 TLS、XTLS 和 REALITY 加密。
 - **内置 AmneziaWG** — 抗 DPI 的 WireGuard 直接在面板内的用户态网络栈上运行,无需内核模块、DKMS 或额外软件包。
+- **内置 TUIC v5** — 基于 QUIC 的高性能代理,支持原生 UDP 中继流量统计、0-RTT 握手和 BBR 拥塞控制。
 - **MTProto 代理** — 按客户端配置 FakeTLS 密钥、广告标签和配额,实时生效且不会断开已有连接。
 - **回落 (Fallback)** — 通过 Xray 的 fallback 功能在单个端口上提供多种协议(例如在 443 端口上同时使用 VLESS 和 Trojan)。
 - **按客户端管理** — 流量配额、到期日期、可豁免受信任地址的 IP 限制、HWID 设备数限制、定时续期周期、实时在线状态,以及一键分享链接、二维码和订阅。
@@ -36,7 +37,7 @@
 - **多节点支持** — 从单一面板管理并扩展到多台服务器,并可将入站克隆到其他节点。
 - **出站与路由** — WARP、NordVPN、PIA、自定义路由规则、支持均衡器间回退的负载均衡器,以及出站代理链。内置的 geosite 与 geoip 分类可直接在规则编辑器中浏览。
 - **内置订阅服务器** — 提供 raw、JSON 和 Clash 输出,可依据客户端 User-Agent 自动选择,并支持[自定义页面模板](docs/custom-subscription-templates.md)。
-- **Telegram 机器人**,用于远程监控和管理。
+- **Telegram 和 Discord 机器人**,用于远程监控和管理。
 - **RESTful API**,支持带作用域、可设置有效期的令牌,并提供面板内置的 API 参考文档。
 - **可安装面板 (PWA)** — 将 3X-UI 固定到桌面或手机主屏幕。
 - **灵活的存储** — SQLite(默认)或 PostgreSQL。

+ 1 - 1
docs/README.md

@@ -43,7 +43,7 @@ The documentation walks you through 3x-ui from first install to day-to-day opera
 
 - **Getting Started** — installation, first login, and updating or uninstalling the panel.
 - **Configuration** — the panel, inbounds, REALITY, transports, clients, subscriptions, and share links.
-- **Operations** — reverse proxy, multi-node setups, outbounds & routing, backup/restore, the Telegram bot, and security.
+- **Operations** — reverse proxy, multi-node setups, outbounds & routing, backup/restore, Telegram and Discord bots, and security.
 - **Reference** — environment variables, the database, ports & firewall, and the HTTP API.
 - **Help** — troubleshooting, FAQ, migration, and how to contribute.
 

+ 41 - 31
docs/architecture.md

@@ -19,13 +19,19 @@ Xray JSON config from that state, supervises the Xray child process, and exposes
 WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the UI. A second,
 separate HTTP server serves **subscription links** to end users.
 
-The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
-inbounds exist — the `mtg-multi` Telegram-proxy binary (`github.com/mhsanaei/mtg-multi`, a
-multi-secret fork built from source; `internal/mtproto/`). One process per inbound serves
-every attached client's FakeTLS secret through the fork's `[secrets]` section, plus optional
-per-client sponsored-channel ad-tags via `[secret-ad-tags]`. A client or ad-tag edit is
-hot-applied via the fork's management API (`PUT /secrets`, guarded by a per-process bearer
-token), with a process restart as the fallback on older binaries.
+The panel supervises **managed child processes**: Xray-core itself and — when MTProto or
+TUIC inbounds exist — dedicated child proxy binaries:
+
+- **`mtg-multi` for MTProto inbounds** (`github.com/mhsanaei/mtg-multi`, a multi-secret fork
+  built from source; `internal/mtproto/`): One process per inbound serves every attached
+  client's FakeTLS secret through the fork's `[secrets]` section, plus optional per-client
+  sponsored-channel ad-tags via `[secret-ad-tags]`. A client or ad-tag edit is hot-applied via
+  the fork's management API (`PUT /secrets`, guarded by a per-process bearer token), with a
+  process restart as the fallback on older binaries.
+- **`tuic-server` for TUIC v5 inbounds** (`internal/tuic/`): One process per inbound runs on
+  loopback behind an in-process native Go UDP relay that owns the public port and meters
+  traffic deltas. The sidecar handles decrypted client traffic standalone, independent of
+  Xray routing and outbounds.
 
 Servers and processes, all launched from `main.go`:
 
@@ -35,6 +41,7 @@ Servers and processes, all launched from `main.go`:
 | **Subscription** | `internal/sub`                    | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
 | **Xray-core**    | supervised via `internal/xray`    | The actual proxy engine; a child process, not Go code              | `inbounds[].port` |
 | **mtg-multi**    | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret)    | per inbound       |
+| **tuic-server**  | supervised via `internal/tuic`    | TUIC v5 proxy child process fronted by a Go UDP relay              | per inbound       |
 
 Two key ideas that explain most of the complexity:
 
@@ -58,7 +65,7 @@ Two key ideas that explain most of the complexity:
 - Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs.
 - Xray: **xtls/xray-core** vendored as a library; the panel talks to the running core over
   its **gRPC API** and also shells out to manage the process.
-- Telegram bot: **mymmrac/telego**. i18n: **nicksnyder/go-i18n**.
+- Bots: Telegram bot (**mymmrac/telego**), Discord bot (Discord REST API v10 + **gorilla/websocket** Gateway v10). i18n: **nicksnyder/go-i18n**.
 - Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP).
 
 **Frontend (`frontend/`):**
@@ -210,6 +217,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   │   │   ├── user.go             #   admin user auth (bcrypt)
 │   │   │   │   ├── api_token.go        #   API token CRUD (SHA-256 hashed)
 │   │   │   │   └── websocket.go        #   WS hub / push service
+│   │   │   ├── discord/                # Discord bot client, Gateway v10, and subscriber
 │   │   │   └── tgbot/                  # Telegram bot command handlers
 │   │   ├── runtime/            # ⭐⭐ The Local/Remote node abstraction (see §5.2)
 │   │   │   ├── runtime.go      #   the Runtime interface (the contract)
@@ -365,27 +373,28 @@ Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.Traffic
 
 All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
 
-| Schedule            | Job                                                                                              | Purpose / condition                                                             |
-| ------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
-| `@every 1s`         | `check_xray_running_job`                                                                         | Restart Xray if it died (2 consecutive down checks)                             |
-| `@every 30s`        | (inline func in `startTask`)                                                                     | Debounced Xray restart — consumes the "need restart" flag (§5.1)                |
-| `@every 5s`         | `xray_traffic_job`                                                                               | Pull traffic stats from Xray (5s start delay)                                   |
-| `@every 5s`         | `node_heartbeat_job`                                                                             | Probe child nodes (online/offline)                                              |
-| `@every 5s`         | `node_traffic_sync_job`                                                                          | Pull + merge node traffic; push reconciliation                                  |
-| `@every 10s`        | `check_client_ip_job`                                                                            | Enforce per-client IP limits                                                    |
-| `@every 10s`        | `mtproto_job`                                                                                    | Reconcile `mtg` sidecars against enabled MTProto inbounds                       |
-| `@every 10s`        | `amneziawg_job`                                                                                  | Reconcile embedded AmneziaWG interfaces against enabled local inbounds          |
-| `@every 5m`         | `outbound_subscription_job`                                                                      | Refresh outbound provider configs                                               |
-| `@every 10m`        | `clear_logs_job` (`PruneXrayLogsJob`)                                                            | Truncate Xray access/error logs once either exceeds 64 MiB                      |
-| `@hourly`           | `warp_ip_job`, `periodic_traffic_reset_job("hourly")`                                            | WARP IP rotation; traffic resets                                                |
-| `@daily`            | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
-| `@weekly`           | `periodic_traffic_reset_job("weekly")`                                                           | Weekly traffic resets                                                           |
-| default `@every 1m` | `ldap_sync_job`                                                                                  | Only if LDAP enabled; schedule configurable                                     |
-| default `@daily`    | `stats_notify_job`                                                                               | Only if TG bot enabled; schedule configurable                                   |
-| `@every 2m`         | `check_hash_storage`                                                                             | Only if TG bot enabled; expires bot callback hashes                             |
-| `@every 1m`         | `check_cpu_usage`                                                                                | Only if a CPU alarm is configured (TG or email); publishes `cpu.high`           |
-| `@every 1m`         | `check_memory_usage`                                                                             | Only if a memory alarm is configured; publishes `memory.high`                   |
-| configurable        | `free_os_memory`                                                                                 | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS            |
+| Schedule            | Job                                                                                              | Purpose / condition                                                                   |
+| ------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
+| `@every 1s`         | `check_xray_running_job`                                                                         | Restart Xray if it died (2 consecutive down checks)                                   |
+| `@every 30s`        | (inline func in `startTask`)                                                                     | Debounced Xray restart — consumes the "need restart" flag (§5.1)                      |
+| `@every 5s`         | `xray_traffic_job`                                                                               | Pull traffic stats from Xray (5s start delay)                                         |
+| `@every 5s`         | `node_heartbeat_job`                                                                             | Probe child nodes (online/offline)                                                    |
+| `@every 5s`         | `node_traffic_sync_job`                                                                          | Pull + merge node traffic; push reconciliation                                        |
+| `@every 10s`        | `check_client_ip_job`                                                                            | Enforce per-client IP limits                                                          |
+| `@every 10s`        | `mtproto_job`                                                                                    | Reconcile `mtg` sidecars against enabled MTProto inbounds                             |
+| `@every 10s`        | `amneziawg_job`                                                                                  | Reconcile embedded AmneziaWG interfaces against enabled local inbounds                |
+| `@every 5m`         | `outbound_subscription_job`                                                                      | Refresh outbound provider configs                                                     |
+| `@every 10m`        | `clear_logs_job` (`PruneXrayLogsJob`)                                                            | Truncate Xray access/error logs once either exceeds 64 MiB                            |
+| `@hourly`           | `warp_ip_job`, `periodic_traffic_reset_job("hourly")`                                            | WARP IP rotation; traffic resets                                                      |
+| `@daily`            | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets       |
+| `@weekly`           | `periodic_traffic_reset_job("weekly")`                                                           | Weekly traffic resets                                                                 |
+| default `@every 1m` | `ldap_sync_job`                                                                                  | Only if LDAP enabled; schedule configurable                                           |
+| default `@daily`    | `stats_notify_job`                                                                               | Only if TG bot enabled; schedule configurable                                         |
+| default `@daily`    | `discord_notify_job`                                                                             | Only if Discord bot enabled; schedule configurable                                    |
+| `@every 2m`         | `check_hash_storage`                                                                             | Only if TG bot enabled; expires bot callback hashes                                   |
+| `@every 1m`         | `check_cpu_usage`                                                                                | Only if a CPU alarm is configured (TG, Discord, or email); publishes `cpu.high`       |
+| `@every 1m`         | `check_memory_usage`                                                                             | Only if a memory alarm is configured (TG, Discord, or email); publishes `memory.high` |
+| configurable        | `free_os_memory`                                                                                 | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS                  |
 
 To change _when_ something runs, edit `startTask()`. To change _what_ it does, edit the job file.
 
@@ -426,7 +435,7 @@ also has protocol schemas under `frontend/src/schemas/protocols/` and `frontend/
 `xray.crash`, `node.down|up`, `cpu.high`, `memory.high`, `login.attempt`, with structured
 payloads (OutboundHealthData, NodeHealthData, LoginEventData, SystemMetricData). Producers
 include the CPU/memory jobs, node heartbeat, and login handling; consumers include the
-Telegram bot and the email notifier (`service/email/`). Use it for cross-cutting
+Telegram bot, the Discord bot (`service/discord/`), and the email notifier (`service/email/`). Use it for cross-cutting
 notifications instead of importing notification services into producers.
 
 ### 5.8 Tunnel health monitor
@@ -497,6 +506,7 @@ for AutoMigrate in `internal/database/db.go`.
 | **Geo category browser** empty / won't open                                       | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go`                 | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` |
 | **`geosite:`/`geoip:` token** reported unknown in a routing rule                  | `xray/geodata/token.go`, `service/geodata.go` (`Validate`)                   | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/`                            |
 | **Telegram bot** commands                                                         | `service/tgbot/`                                                             | `job/stats_notify_job.go`                                                                           |
+| **Discord bot** commands & reports                                                | `service/discord/`                                                           | `job/discord_notify_job.go`                                                                         |
 | **Email notifications**                                                           | `service/email/`                                                             | `internal/eventbus/` (consumers)                                                                    |
 | **CPU / memory alerts** not firing                                                | `job/check_cpu_usage.go`, `job/check_memory_usage.go`                        | `internal/eventbus/`, notifier settings in `service/setting.go`                                     |
 | Xray auto-restart on **dead tunnel**                                              | `internal/tunnelmonitor/`                                                    | `XUI_TUNNEL_HEALTH_*` in `internal/config/`                                                         |
@@ -532,7 +542,7 @@ for AutoMigrate in `internal/database/db.go`.
 8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an _end user_
    fetches goes in `internal/sub`. Don't blur them.
 9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead
-   of importing the Telegram/email services into producers.
+   of importing the Telegram/Discord/email services into producers.
 
 ---
 

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

@@ -13,12 +13,12 @@ inbounds** at once, with per-client traffic accounting.
 | Field          | Applies to            | Meaning                                                            |
 | -------------- | --------------------- | ------------------------------------------------------------------ |
 | **Email**      | all                   | Unique identifier used for accounting and lookups.                 |
-| **ID (UUID)**  | VLESS, VMess          | The client credential.                                             |
-| **Password**   | Trojan, Shadowsocks   | The client credential.                                             |
+| **ID (UUID)**  | VLESS, VMess, TUIC    | The client credential.                                             |
+| **Password**   | Trojan, Shadowsocks, TUIC | The client credential.                                             |
 | **Auth**       | Hysteria2             | The client credential.                                             |
 | **Flow**       | VLESS                 | XTLS flow, e.g. `xtls-rprx-vision`.                                |
-| **Limit IP**   | all                   | Max simultaneous source IPs (enforced via Fail2ban).               |
-| **Total (GB)** | all                   | Traffic quota; the client is disabled when exhausted.              |
+| **Limit IP**   | all (except TUIC)     | Max simultaneous source IPs (enforced via Fail2ban).               |
+| **Total (GB)** | all (except TUIC)     | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
 | **Expiry**     | all                   | Date after which the client stops working.                        |
 | **Reset**      | all                   | Auto-renew period in **days** (rolls the quota over).              |
 | **Telegram ID**| all                   | Links the client to a Telegram user for self-service/notifications.|

+ 27 - 2
docs/content/docs/en/config/subscription.mdx

@@ -50,8 +50,8 @@ The **format is chosen by path**, each with its own enable toggle:
 | **Mihomo (explicit)**          | `/mihomo/`       | `subClashEnable` | Alias for the full `subClashPath` profile.          |
 | **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
 
-Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, or Hysteria2**
-appear in a subscription, ordered by their sub-sort index. Requesting `subPath`
+Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, MTProto, TUIC, or Hysteria2**
+appear in a subscription, ordered by their sub-sort index (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
 with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
 page instead of the raw body.
 
@@ -94,6 +94,31 @@ Subscriptions return standard headers that compatible apps read:
 - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
   **`Announce`** — optional branding shown by some clients.
 
+### Optional month-end expiry display
+
+Under **Subscription → Information**, **Month-end subscription expiry display**
+(`subCalendarExpireInclusive`, default `false`) reports the last valid second
+of the month in `Subscription-Userinfo` instead of the next month's midnight.
+It applies only when every client contributing to the subscription has calendar
+renewal day `1`, shares the same fixed expiry, and that expiry is exactly day `1`
+at `00:00:00` in the configured panel timezone, immediately after the previous
+month's last second. A later repeated midnight during a DST rollback is not
+converted. Raw, JSON, Mihomo, and legacy
+Clash subscriptions use the same conversion.
+
+For example, the real cutoff `2030-10-01 00:00:00` is presented as
+`2030-09-30 23:59:59`. The stored expiry, access cutoff, traffic accounting,
+renewal schedule, remark expiry variables, and HTML/JSON info-page cutoff stay
+unchanged. Arbitrary times, other renewal days, interval renewal, first-use
+durations, unlimited expiries, mixed renewal modes, and different cutoffs are
+not converted.
+
+This is an opt-in compatibility tradeoff, not a change to expiry semantics by
+default: apps receive a timestamp one second before the real cutoff and may
+consider the subscription expired one second early. Apps format it in their own
+timezone; matching the panel timezone is needed to display the same month-end
+date. Cached subscription information changes only after the app refreshes it.
+
 ## Custom page templates
 
 Point `subThemeDir` at a folder containing a custom info-page template to brand

+ 2 - 2
docs/content/docs/en/guide/index.mdx

@@ -34,13 +34,13 @@ flowchart LR
 ## What it gives you
 
 - A dashboard for **inbounds** across every major protocol — VLESS, VMess,
-  Trojan, Shadowsocks, WireGuard, Hysteria2, SOCKS, HTTP, and Dokodemo-door.
+  Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, SOCKS, HTTP, and Dokodemo-door.
 - First-class **REALITY** and **XTLS-Vision** support for stealthy, fast
   transports.
 - **Per-client** traffic quotas, expiry dates, IP limits, online status, and
   one-click share links / QR codes.
 - **Subscriptions** in VLESS, Clash/Mihomo, and JSON formats.
-- Operational tooling: **multi-node** management, a **Telegram bot**, backups,
+- Operational tooling: **multi-node** management, **Telegram and Discord bots**, backups,
   Fail2ban-based IP limiting, and a documented REST API.
 
 ## Under the hood

+ 2 - 2
docs/content/docs/en/index.mdx

@@ -41,12 +41,12 @@ leaves the page.
 ## Highlights
 
 - **Every major protocol** — VLESS, VMess, Trojan, Shadowsocks, WireGuard,
-  Hysteria2, SOCKS, HTTP, and Dokodemo-door.
+  AmneziaWG, TUIC v5, Hysteria2, SOCKS, HTTP, and Dokodemo-door.
 - **REALITY & XTLS-Vision** — modern, censorship-resistant transports.
 - **Per-client controls** — traffic quotas, expiry dates, IP limits, share
   links, and QR codes.
 - **Subscriptions** — VLESS, Clash/Mihomo, and JSON formats.
-- **Operations** — multi-node management, Telegram bot, backups, and a REST API.
+- **Operations** — multi-node management, Telegram and Discord bots, backups, and a REST API.
 
 <Callout type="info">
   New to Xray? Read [What is 3x-ui?](/docs/guide) first — it explains how the panel, Xray-core, and

+ 2 - 6
docs/content/docs/en/operations/backup-restore.mdx

@@ -31,13 +31,9 @@ To restore, stop the panel, put the database back in place, and start it again.
   old schema.
 </Callout>
 
-## Telegram backup
+## Automated bot backups (Telegram & Discord)
 
-If you've configured the [Telegram bot](/docs/operations/telegram-bot), enable
-**`tgBotBackup`** to attach a backup to the periodic report (on the `tgRunTime`
-schedule, default daily). The bot sends both the **database** and the **Xray
-`config.json`** to your admin chat, so you always have an off-server copy. Admins
-can also request a backup on demand from the bot's menu.
+If you've configured the [Telegram bot](/docs/operations/telegram-bot) or [Discord bot](/docs/operations/discord-bot), enable **`tgBotBackup`** or **`discordBotBackup`** to attach a backup to the periodic report (on the `tgRunTime` / `discordRunTime` schedule, default daily). The bot sends both the **database** and the **Xray `config.json`** directly to your admin chat or channel, ensuring an off-server copy. Admins can also request a backup on demand from the Telegram bot's menu or using `!backup` in Discord.
 
 ## SQLite dump / restore
 

+ 1 - 1
docs/content/docs/en/operations/multi-node.mdx

@@ -31,7 +31,7 @@ Provide the node's connection details:
 The master verifies reachability when you add or test a node. It then sends a
 **heartbeat** every few seconds, updating the node's status (`online` / `offline`)
 and emitting `node.up` / `node.down` events (see the
-[Telegram bot](/docs/operations/telegram-bot)).
+[Telegram bot](/docs/operations/telegram-bot) and [Discord bot](/docs/operations/discord-bot)).
 
 <Callout type="info">
   Nodes are identified by a stable per-panel GUID, so a node keeps its identity

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

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

+ 2 - 0
docs/content/docs/fa/config/inbounds.mdx

@@ -58,11 +58,13 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
 | **Trojan**             | مبتنی بر TLS؛ از XTLS و fallback پشتیبانی می‌کند.                         |
 | **Shadowsocks**        | شامل رمزهای Shadowsocks-2022 (`2022-blake3-*`).                          |
 | **WireGuard**          | تونل مدرن.                                                               |
+| **AmneziaWG**          | نسخه مبهم‌شده فورک WireGuard که در فرایند پنل تعبیه شده است. مشاهده [AmneziaWG](/docs/config/amneziawg). |
 | **Hysteria2**          | با عنوان `hysteria` انتخاب می‌شود؛ پنل لینک‌های `hysteria2://` تولید می‌کند. |
 | **HTTP**               | پراکسی HTTP.                                                             |
 | **Mixed (SOCKS/HTTP)** | یک شنونده ترکیبی SOCKS + HTTP.                                            |
 | **Dokodemo-door / Tunnel** | فورواردینگ پورت / هدایت ترافیک.                                      |
 | **MTProto**            | پراکسی MTProto تلگرام که توسط یک فرایند همراه `mtg` سرویس می‌شود (نه Xray). |
+| **TUIC**               | پروتکل پراکسی مبتنی بر QUIC نسخه ۵ که توسط فرایند `tuic-server` ارائه می‌شود. مشاهده [TUIC](/docs/config/tuic). |
 
 <Callout type="info">
   Hysteria2 در سطح داخلی یک پروتکل جداگانه نیست — همان پروتکل `hysteria` است که

+ 1 - 0
docs/content/docs/fa/config/panel.mdx

@@ -67,6 +67,7 @@ icon: SlidersHorizontal
 
 <Cards>
   <Card title="ربات Telegram" href="/docs/operations/telegram-bot" description="توکن، شناسه‌های چت، هشدارها و گزارش‌ها." />
+  <Card title="ربات Discord" href="/docs/operations/discord-bot" description="توکن، شناسه کانال و هشدارهای رویداد." />
   <Card title="اشتراک" href="/docs/config/subscription" description="سرور اشتراک، قالب‌ها و مسیرها." />
   <Card title="امنیت" href="/docs/operations/security" description="۲FA، محدودیت‌های IP و سخت‌سازی." />
 </Cards>

+ 2 - 3
docs/content/docs/fa/config/subscription.mdx

@@ -48,9 +48,8 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 | **JSON**              | `subJsonPath`  | `subJsonEnable`  | پیکربندی(های) کامل کلاینت Xray.                      |
 | **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | پروفایل YAML.                                        |
 
-فقط ورودی‌های فعالی که از **VLESS، VMess، Trojan، Shadowsocks یا Hysteria2**
-استفاده می‌کنند در یک اشتراک ظاهر می‌شوند و بر اساس شاخص sub-sort آن‌ها مرتب می‌شوند.
-درخواست `subPath` همراه با هدر `Accept: text/html` (یا `?html=1`) به‌جای بدنه‌ی خام،
+فقط ورودی‌های فعالی که از **VLESS، VMess، Trojan، Shadowsocks، WireGuard، AmneziaWG، MTProto، TUIC یا Hysteria2**
+استفاده می‌کنند در یک اشتراک ظاهر می‌شوند و بر اساس شاخص sub-sort آن‌ها مرتب می‌شوند (TUIC و AmneziaWG در لینک‌های خام و پروفایل‌های Clash/Mihomo گنجانده می‌شوند اما از اندپوینت‌های JSON حذف می‌شوند؛ MTProto در لینک‌های خام گنجانده می‌شود). درخواست `subPath` همراه با هدر `Accept: text/html` (یا `?html=1`) به‌جای بدنه‌ی خام،
 یک صفحه‌ی اطلاعات خوانا برای انسان برمی‌گرداند.
 
 ### Base64 vs JSON

+ 2 - 2
docs/content/docs/fa/guide/index.mdx

@@ -35,13 +35,13 @@ flowchart LR
 ## چه چیزی در اختیار شما می‌گذارد
 
 - داشبوردی برای **ورودی‌ها** در تمام پروتکل‌های اصلی — VLESS، VMess،
-  Trojan، Shadowsocks، WireGuard، Hysteria2، SOCKS، HTTP و Dokodemo-door.
+  Trojan، Shadowsocks، WireGuard، AmneziaWG، TUIC v5، Hysteria2، SOCKS، HTTP و Dokodemo-door.
 - پشتیبانی درجه‌یک از **REALITY** و **XTLS-Vision** برای ترانسپورت‌های مخفی
   و سریع.
 - سهمیه‌های ترافیک **به‌ازای هر کلاینت**، تاریخ‌های انقضا، محدودیت‌های IP،
   وضعیت آنلاین و لینک‌های اشتراک‌گذاری / کدهای QR با یک کلیک.
 - **اشتراک‌ها** در قالب‌های VLESS، Clash/Mihomo و JSON.
-- ابزارهای عملیاتی: مدیریت **چندنودی**، یک **ربات Telegram**، پشتیبان‌گیری،
+- ابزارهای عملیاتی: مدیریت **چندنودی**، **ربات‌های Telegram و Discord**، پشتیبان‌گیری،
   محدودسازی IP مبتنی بر Fail2ban و یک REST API مستندشده.
 
 ## پشت صحنه

+ 2 - 2
docs/content/docs/fa/index.mdx

@@ -41,12 +41,12 @@ icon: House
 ## ویژگی‌های شاخص
 
 - **همه پروتکل‌های اصلی** — VLESS، VMess، Trojan، Shadowsocks، WireGuard،
-  Hysteria2، SOCKS، HTTP و Dokodemo-door.
+  AmneziaWG، TUIC v5، Hysteria2، SOCKS، HTTP و Dokodemo-door.
 - **REALITY و XTLS-Vision** — ترنسپورت‌های مدرن و مقاوم در برابر سانسور.
 - **کنترل‌های اختصاصی هر کلاینت** — سهمیه ترافیک، تاریخ انقضا، محدودیت IP، لینک‌های
   اشتراک‌گذاری و کدهای QR.
 - **سابسکریپشن‌ها** — قالب‌های VLESS، Clash/Mihomo و JSON.
-- **عملیات** — مدیریت چندنودی، ربات Telegram، پشتیبان‌گیری و یک REST API.
+- **عملیات** — مدیریت چندنودی، ربات‌های Telegram و Discord، پشتیبان‌گیری و یک REST API.
 
 <Callout type="info">
   با Xray تازه آشنا شده‌اید؟ ابتدا [3x-ui چیست؟](/docs/guide) را بخوانید — توضیح می‌دهد که پنل، Xray-core و

+ 2 - 6
docs/content/docs/fa/operations/backup-restore.mdx

@@ -31,13 +31,9 @@ cp /etc/x-ui/x-ui.db /root/x-ui-backup-$(date +%F).db
   مهاجرت‌های خود را اجرا کند.
 </Callout>
 
-## پشتیبان‌گیری با Telegram
+## پشتیبان‌گیری خودکار با ربات‌ها (Telegram و Discord)
 
-اگر [ربات Telegram](/docs/operations/telegram-bot) را پیکربندی کرده‌اید، گزینه‌ی
-**`tgBotBackup`** را فعال کنید تا یک نسخه‌ی پشتیبان به گزارش دوره‌ای ضمیمه شود (بر اساس
-زمان‌بندی `tgRunTime`، به‌صورت پیش‌فرض روزانه). ربات هم **پایگاه‌داده** و هم **`config.json`
-مربوط به Xray** را به چت ادمین شما می‌فرستد، بنابراین همیشه یک نسخه‌ی خارج از سرور در اختیار
-دارید. ادمین‌ها همچنین می‌توانند به‌صورت درخواستی از منوی ربات یک نسخه‌ی پشتیبان بخواهند.
+اگر [ربات Telegram](/docs/operations/telegram-bot) یا [ربات Discord](/docs/operations/discord-bot) را پیکربندی کرده‌اید، گزینه‌ی **`tgBotBackup`** یا **`discordBotBackup`** را فعال کنید تا یک نسخه‌ی پشتیبان به گزارش دوره‌ای ضمیمه شود (بر اساس زمان‌بندی `tgRunTime` / `discordRunTime`، به‌صورت پیش‌فرض روزانه). ربات هم **پایگاه‌داده** و هم **`config.json` مربوط به Xray** را مستقیماً به چت یا کانال ادمین شما می‌فرستد، بنابراین همیشه یک نسخه‌ی خارج از سرور در اختیار دارید. ادمین‌ها همچنین می‌توانند به‌صورت درخواستی از منوی ربات Telegram یا با دستور `!backup` در Discord یک نسخه‌ی پشتیبان دریافت کنند.
 
 ## دامپ / بازیابی SQLite
 

+ 121 - 0
docs/content/docs/fa/operations/discord-bot.mdx

@@ -0,0 +1,121 @@
+---
+title: ربات Discord
+description: یک ربات Discord را به 3x-ui متصل کنید تا اعلان‌های بی‌درنگ Embed، گزارش‌های دوره‌ای همراه با نسخه پشتیبان پایگاه‌داده و فرمان‌های تعاملی را در یک کانال دریافت کنید.
+icon: Bot
+---
+
+3x-ui یکپارچگی کاملی با Discord فراهم می‌کند: ارسال هشدارهای بی‌درنگ از طریق گذرگاه رویدادها (`EventBus`)، گزارش‌های دوره‌ای وضعیت سرور به‌همراه فایل پشتیبان پایگاه‌داده، و پردازش فرمان‌های تعاملی از طریق Discord Gateway.
+
+<Callout type="info">
+  اعلان‌های لحظه‌ای و گزارش‌های دوره‌ای از تماس‌های خروجی HTTPS به Discord REST API v10 استفاده می‌کنند. فرمان‌های تعاملی ربات نیز از طریق یک اتصال پس‌زمینه WebSocket امن به Discord Gateway برقرار می‌شوند.
+</Callout>
+
+## راه‌اندازی
+
+<Steps>
+
+<Step>
+### ساخت برنامه و ربات در Discord
+
+1. وارد [Discord Developer Portal](https://discord.com/developers/applications) شوید.
+2. روی **New Application** در بالا سمت راست کلیک کنید، یک نام مشخص کنید (مثلاً `3x-ui Notifier`) و تایید نمایید.
+3. در نوار کناری چپ، به تب **Bot** بروید.
+4. روی **Reset Token** (یا **Add Bot**) کلیک کنید و **Bot Token** را کپی نمایید. این توکن را محفوظ نگه دارید.
+5. در بخش **Privileged Gateway Intents**، گزینه **Message Content Intent** را فعال کنید (برای خواندن فرمان‌هایی مانند `!status` ضروری است).
+</Step>
+
+<Step>
+### دعوت ربات به سرور Discord
+
+1. در پرتال توسعه‌دهندگان، به **OAuth2** → **URL Generator** بروید.
+2. در بخش **Scopes**، گزینه `bot` را علامت بزنید.
+3. در بخش **Bot Permissions**، دسترسی‌های زیر را انتخاب کنید:
+   - **Send Messages** (ارسال پیام)
+   - **Embed Links** (ارسال امبدها)
+   - **Attach Files** (پیوست فایل‌ها — جهت ارسال نسخه پشتیبان پایگاه‌داده ضروری است)
+   - **Read Message History** (خواندن تاریخچه پیام‌ها)
+4. لینک تولیدشده در پایین صفحه را کپی کرده و در مرورگر باز کنید تا ربات به سرور شما اضافه شود.
+</Step>
+
+<Step>
+### کپی کردن Channel ID
+
+1. در کلاینت دیسکورد، حالت توسعه‌دهنده را فعال کنید: **User Settings** → **Advanced** → **Developer Mode** (روشن).
+2. روی کانالی که می‌خواهید اعلان‌ها و تعامل با ربات در آن انجام شود راست‌کلیک کرده و **Copy Channel ID** را انتخاب کنید.
+3. مطمئن شوید ربات دسترسی مشاهده و ارسال پیام در این کانال را دارد.
+</Step>
+
+<Step>
+### پیکربندی پنل
+
+1. در پنل 3x-ui، به **تنظیمات پنل** → **ربات Discord** (یا آدرس `/settings#discord`) بروید.
+2. در بخش **عمومی**:
+   - گزینه **فعال‌سازی اعلان‌های Discord** را روشن کنید.
+   - **Bot Token** و **Channel ID** خود را وارد کنید.
+   - شناسه کاربری عددی دیسکورد خود را در **شناسه‌های کاربری ادمین** وارد نمایید (راست‌کلیک روی نام خودتان → **Copy User ID**؛ شناسه‌های متعدد را با کاما جدا کنید).
+   - زبان مورد نظر خود برای ربات را انتخاب کنید.
+3. در بخش **اعلان‌ها**:
+   - زمان‌بندی گزارش‌ها را تنظیم کنید (مثلاً `@daily`، `@weekly` یا عبارت crontab سفارشی).
+   - در صورت تمایل، گزینه **پشتیبان‌گیری پایگاه‌داده** را فعال کنید تا فایل `x-ui.db` به‌صورت خودکار ضمیمه گزارش‌ها شود.
+   - رویدادهای مورد نظر برای دریافت هشدار و آستانه‌های بار CPU/RAM را تنظیم نمایید.
+4. روی **ارسال اعلان آزمایشی** کلیک کنید تا از صحت ارتباط مطمئن شوید.
+5. برای اعمال تغییرات روی **ذخیره** کلیک نمایید.
+</Step>
+
+</Steps>
+
+## فرمان‌های ربات
+
+هنگام فعال بودن، ربات به فرمان‌های ارسال‌شده در کانال پیکربندی‌شده گوش می‌دهد (پشتیبانی از هر دو پیشوند `!` و `/`). تنها کاربرانی که شناسه‌ی آن‌ها در **شناسه‌های کاربری ادمین** ثبت شده مجاز به اجرای فرمان‌ها هستند؛ پیام‌های سایر کاربران نادیده گرفته می‌شود و در صورت خالی بودن این فیلد، اجرای فرمان‌ها غیرفعال خواهد بود. فرمان `!backup` فایل پایگاه‌داده را در کانال ارسال می‌کند، بنابراین کانالی را انتخاب کنید که فقط ادمین‌ها به آن دسترسی داشته باشند:
+
+| فرمان | عملکرد |
+| ----- | ------ |
+| `!status` | نمایش بار پردازشی سیستم، مصرف RAM، وضعیت هسته Xray، اتصالات و تعداد کاربران آنلاین. |
+| `!report` | تولید و ارسال فوری گزارش کامل وضعیت سرور و پروکسی. |
+| `!backup` | ارسال فوری فایل نسخه پشتیبان پایگاه‌داده (`x-ui.db`) و `config.json`. |
+| `!usage <email>` | بررسی مصرف ترافیک (دانلود/آپلود)، سقف حجم و تاریخ انقضای یک کلاینت خاص. |
+| `!inbounds` | فهرست تمام اینباندهای فعال به همراه پورت، پروتکل، ترافیک و تعداد کلاینت‌ها. |
+| `!restart` | راه‌اندازی مجدد ایمن هسته Xray بدون نیاز به ری‌استارت پنل تحت وب. |
+| `!help` | نمایش فهرست فرمان‌های در دسترس ربات. |
+
+## هشدارهای رویدادها
+
+هشدارها به‌صورت ساختاریافته در قالب Discord Embed همراه با رنگ‌بندی تشخیصی ارسال می‌شوند:
+
+| رویداد | نشانگر | توضیح |
+| ------ | ------ | ------ |
+| `xray.crash` | 🔴 قرمز | کرش کردن هسته Xray؛ همراه با علت و زمان دقیق |
+| `outbound.down` | 🔴 قرمز | شکست در آزمون اتصال اوتباند |
+| `outbound.up` | 🟢 سبز | برقراری مجدد اتصال اوتباند |
+| `node.down` | 🔴 قرمز | خارج از دسترس شدن یا قطع اتصال نود راه دور |
+| `node.up` | 🟢 سبز | اتصال مجدد و بازگشت سلامت نود راه دور |
+| `cpu.high` | 🟠 نارنجی | عبور میزان مصرف CPU از آستانه تعیین‌شده (`discordCpu`) |
+| `memory.high` | 🟠 نارنجی | عبور میزان مصرف RAM از آستانه تعیین‌شده (`discordMemory`) |
+| `login.attempt` | 🟢 / 🔴 | تلاش برای ورود به پنل تحت وب همراه با نام کاربری، IP و وضعیت ورود |
+
+<Callout type="warn">
+  هشدارهای ورود فقط نام کاربری و آدرس IP کلاینت را گزارش می‌دهند. رمزهای عبور هرگز ذخیره یا ارسال نمی‌شوند.
+</Callout>
+
+## راهنمای تنظیمات
+
+| پارامتر | مقدار پیش‌فرض | توضیح |
+| ------- | ------------- | ------ |
+| `discordBotEnable` | `false` | کلید اصلی فعال‌سازی ربات و هشدارهای Discord. |
+| `discordBotToken` | _(محرمانه)_ | توکن ربات دریافتی از Discord Developer Portal. |
+| `discordChannelId` | _(خالی)_ | شناسه عددی (Snowflake ID) کانال مقصد در دیسکورد. |
+| `discordAdminIds` | _(خالی)_ | شناسه‌های عددی کاربران مجاز به اجرای فرمان‌ها (با کاما جدا شوند). |
+| `discordLang` | `en-US` | زبان پیام‌ها و گزارش‌های ارسالی ربات دیسکورد. |
+| `discordRunTime` | `@daily` | زمان‌بندی Cron برای ارسال خودکار گزارش وضعیت. |
+| `discordBotBackup` | `false` | ضمیمه کردن خودکار فایل نسخه پشتیبان (`x-ui.db`) به گزارش‌ها. |
+| `discordEnabledEvents` | `login.attempt,cpu.high` | فهرست رویدادهای فعال برای ارسال هشدار (با کاما جدا شوند). |
+| `discordCpu` | `80` | آستانه درصد مصرف پردازنده (CPU) جهت ارسال هشدار (۰ تا ۱۰۰). |
+| `discordMemory` | `80` | آستانه درصد مصرف رم (RAM) جهت ارسال هشدار (۰ تا ۱۰۰). |
+
+## عیب‌یابی
+
+- **خطای invalid bot token (401)**: مطمئن شوید که توکن ربات را به‌طور کامل از تب **Bot** کپی کرده‌اید، نه Client Secret یا Application ID.
+- **خطای missing permissions (403)**: بررسی کنید که رول ربات در کانال یا دسته‌بندی مربوطه دارای دسترسی‌های **Send Messages**، **Embed Links** و **Attach Files** باشد.
+- **عدم پاسخگویی به فرمان‌ها**: بررسی کنید که شناسه‌ی عددی شما در **Admin User IDs** ثبت شده باشد. همچنین مطمئن شوید گزینه **Message Content Intent** در پرتال دیسکورد روشن است و پنل را ری‌استارت کنید؛ دیسکورد در صورت نبود این دسترسی اتصال را قطع می‌کند.
+- **خطای channel not found (404)**: از صحت Channel ID اطمینان حاصل کنید و بررسی کنید که ربات حتماً در سروری که کانال در آن قرار دارد عضو باشد.
+- **پراکسی برای درخواست‌های خروجی**: اگر سرور شما برای اتصال به دیسکورد به پروکسی نیاز دارد، در تنظیمات پنل گزینه **Panel Outbound** را پیکربندی کنید؛ درخواست‌های دیسکورد به‌صورت خودکار از طریق آن هدایت می‌شوند.

+ 1 - 1
docs/content/docs/fa/operations/multi-node.mdx

@@ -25,7 +25,7 @@ icon: Boxes
 | **Inbound sync**  | همهٔ inboundها (`all`) یا انتخاب‌شده (`selected`) بر اساس تگ.          |
 | **Outbound tag**  | به‌اختیار از طریق یک outbound نام‌دار به نود برسید (پل خروجی).         |
 
-مستر هنگام افزودن یا آزمودن یک نود، قابلیت دسترسی به آن را بررسی می‌کند. سپس هر چند ثانیه یک **ضربان قلب (heartbeat)** ارسال می‌کند، وضعیت نود را به‌روزرسانی می‌کند (`online` / `offline`) و رویدادهای `node.up` / `node.down` را منتشر می‌کند (به [بات Telegram](/docs/operations/telegram-bot) مراجعه کنید).
+مستر هنگام افزودن یا آزمودن یک نود، قابلیت دسترسی به آن را بررسی می‌کند. سپس هر چند ثانیه یک **ضربان قلب (heartbeat)** ارسال می‌کند، وضعیت نود را به‌روزرسانی می‌کند (`online` / `offline`) و رویدادهای `node.up` / `node.down` را منتشر می‌کند (به [بات Telegram](/docs/operations/telegram-bot) و [بات Discord](/docs/operations/discord-bot) مراجعه کنید).
 
 <Callout type="info">
   نودها با یک GUID پایدار به‌ازای هر پنل شناسایی می‌شوند، بنابراین یک نود هویت خود

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

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

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

@@ -59,6 +59,7 @@ icon: ArrowDownToLine
 | **Trojan**             | На основе TLS; поддерживает XTLS и fallback-правила.                      |
 | **Shadowsocks**        | Включает шифры Shadowsocks-2022 (`2022-blake3-*`).                        |
 | **WireGuard**          | Современный туннель.                                                      |
+| **AmneziaWG**          | Форк WireGuard с обфускацией, встроенный в процесс панели. См. [AmneziaWG](/docs/config/amneziawg). |
 | **Hysteria2**          | Выбирается как `hysteria`; панель создаёт ссылки `hysteria2://`.          |
 | **HTTP**               | HTTP-прокси.                                                              |
 | **Mixed (SOCKS/HTTP)** | Совмещённый слушатель SOCKS + HTTP.                                       |

+ 2 - 2
docs/content/docs/ru/config/subscription.mdx

@@ -49,8 +49,8 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 | **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML-профиль.                                       |
 
 В подписке появляются только включённые входящие соединения, использующие
-**VLESS, VMess, Trojan, Shadowsocks или Hysteria2**, упорядоченные по их индексу
-сортировки подписки. Запрос `subPath` с заголовком `Accept: text/html` (или
+**VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, MTProto, TUIC или Hysteria2**, упорядоченные по их индексу
+сортировки подписки (TUIC и AmneziaWG включаются в raw-ссылки и профили Clash/Mihomo, но исключаются из JSON-конфигов; MTProto включается в raw-ссылки). Запрос `subPath` с заголовком `Accept: text/html` (или
 `?html=1`) возвращает удобочитаемую информационную страницу вместо
 необработанного тела.
 

+ 2 - 2
docs/content/docs/ru/guide/index.mdx

@@ -34,13 +34,13 @@ flowchart LR
 ## Что она вам даёт
 
 - Панель управления **входящими подключениями** по всем основным протоколам — VLESS, VMess,
-  Trojan, Shadowsocks, WireGuard, Hysteria2, SOCKS, HTTP и Dokodemo-door.
+  Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, SOCKS, HTTP и Dokodemo-door.
 - Полноценная поддержка **REALITY** и **XTLS-Vision** для скрытных и быстрых
   транспортов.
 - **Поклиентские** квоты трафика, даты истечения, ограничения по IP, статус «онлайн» и
   ссылки для подключения / QR-коды в один клик.
 - **Подписки** в форматах VLESS, Clash/Mihomo и JSON.
-- Инструменты для эксплуатации: управление **несколькими узлами**, **Telegram-бот**, резервные копии,
+- Инструменты для эксплуатации: управление **несколькими узлами**, **Telegram- и Discord-боты**, резервные копии,
   ограничение по IP на базе Fail2ban и документированный REST API.
 
 ## Что под капотом

+ 2 - 2
docs/content/docs/ru/index.mdx

@@ -41,12 +41,12 @@ icon: House
 ## Ключевые возможности
 
 - **Все основные протоколы** — VLESS, VMess, Trojan, Shadowsocks, WireGuard,
-  Hysteria2, SOCKS, HTTP и Dokodemo-door.
+  AmneziaWG, TUIC v5, Hysteria2, SOCKS, HTTP и Dokodemo-door.
 - **REALITY и XTLS-Vision** — современные транспорты, устойчивые к цензуре.
 - **Управление каждым клиентом** — квоты трафика, даты истечения, ограничения по IP, ссылки
   для подключения и QR-коды.
 - **Подписки** — форматы VLESS, Clash/Mihomo и JSON.
-- **Эксплуатация** — управление несколькими узлами, Telegram-бот, резервные копии и REST API.
+- **Эксплуатация** — управление несколькими узлами, Telegram- и Discord-боты, резервные копии и REST API.
 
 <Callout type="info">
   Впервые работаете с Xray? Сначала прочитайте [Что такое 3x-ui?](/docs/guide) — там объясняется, как панель, Xray-core и

+ 3 - 8
docs/content/docs/ru/operations/backup-restore.mdx

@@ -33,14 +33,9 @@ cp /etc/x-ui/x-ui.db /root/x-ui-backup-$(date +%F).db
   выполнить миграции, а не навязывайте старую схему.
 </Callout>
 
-## Резервное копирование через Telegram
-
-Если вы настроили [Telegram-бота](/docs/operations/telegram-bot), включите
-**`tgBotBackup`**, чтобы прикреплять резервную копию к периодическому отчёту (по
-расписанию `tgRunTime`, по умолчанию ежедневно). Бот отправляет в чат
-администратора как **базу данных**, так и **`config.json` Xray**, поэтому у вас
-всегда будет копия за пределами сервера. Администраторы также могут запросить
-резервную копию по требованию через меню бота.
+## Автоматические бэкапы ботов (Telegram и Discord)
+
+Если вы настроили [Telegram-бота](/docs/operations/telegram-bot) или [Discord-бота](/docs/operations/discord-bot), включите **`tgBotBackup`** или **`discordBotBackup`**, чтобы прикреплять резервную копию к периодическому отчёту (по расписанию `tgRunTime` / `discordRunTime`, по умолчанию ежедневно). Бот отправляет в чат или канал администратора как **базу данных**, так и **`config.json` Xray**, поэтому у вас всегда будет копия за пределами сервера. Администраторы также могут запросить резервную копию по требованию через меню бота Telegram или с помощью команды `!backup` в Discord.
 
 ## Дамп / восстановление SQLite
 

+ 1 - 1
docs/content/docs/ru/operations/multi-node.mdx

@@ -32,7 +32,7 @@ API этого узла. Главная панель опрашивает каж
 Главная панель проверяет доступность при добавлении или тестировании узла. Затем
 она каждые несколько секунд отправляет **heartbeat**, обновляя статус узла
 (`online` / `offline`) и генерируя события `node.up` / `node.down` (см.
-[Telegram-бот](/docs/operations/telegram-bot)).
+[Telegram-бот](/docs/operations/telegram-bot) и [Discord-бот](/docs/operations/discord-bot)).
 
 <Callout type="info">
   Узлы идентифицируются по стабильному GUID, уникальному для каждой панели,

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

@@ -12,12 +12,12 @@ icon: Users
 | 字段           | 适用于                | 含义                                                              |
 | -------------- | --------------------- | ------------------------------------------------------------------ |
 | **Email**      | 全部                  | 用于统计和查询的唯一标识符。                                       |
-| **ID (UUID)**  | VLESS、VMess          | 客户端凭据。                                                      |
-| **Password**   | Trojan、Shadowsocks   | 客户端凭据。                                                      |
+| **ID (UUID)**  | VLESS、VMess、TUIC    | 客户端凭据。                                                      |
+| **Password**   | Trojan、Shadowsocks、TUIC | 客户端凭据。                                                  |
 | **Auth**       | Hysteria2             | 客户端凭据。                                                      |
 | **Flow**       | VLESS                 | XTLS 流控,例如 `xtls-rprx-vision`。                              |
-| **Limit IP**   | 全部                  | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。             |
-| **Total (GB)** | 全部                  | 流量配额;用尽后客户端将被禁用。                                 |
+| **Limit IP**   | 全部(TUIC 除外)      | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。             |
+| **Total (GB)** | 全部(TUIC 除外)      | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
 | **Expiry**     | 全部                  | 该日期之后客户端停止工作。                                       |
 | **Reset**      | 全部                  | 以**天**为单位的自动续期周期(滚动重置配额)。                   |
 | **Telegram ID**| 全部                  | 将客户端关联到 Telegram 用户,用于自助服务/通知。               |

+ 2 - 0
docs/content/docs/zh/config/inbounds.mdx

@@ -55,11 +55,13 @@ icon: ArrowDownToLine
 | **Trojan**             | 基于 TLS;支持 XTLS 和回落。                                              |
 | **Shadowsocks**        | 包含 Shadowsocks-2022(`2022-blake3-*`)加密方式。                        |
 | **WireGuard**          | 现代隧道协议。                                                            |
+| **AmneziaWG**          | 混淆版 WireGuard 分支,直接内置在面板进程中。参见 [AmneziaWG](/docs/config/amneziawg)。 |
 | **Hysteria2**          | 选择为 `hysteria`;面板生成 `hysteria2://` 链接。                         |
 | **HTTP**               | HTTP 代理。                                                               |
 | **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 并不是一个独立的协议——它是把传输版本设为 2 的 `hysteria`

+ 1 - 0
docs/content/docs/zh/config/panel.mdx

@@ -63,6 +63,7 @@ icon: SlidersHorizontal
 
 <Cards>
   <Card title="Telegram 机器人" href="/docs/operations/telegram-bot" description="令牌、聊天 ID、告警与报告。" />
+  <Card title="Discord 机器人" href="/docs/operations/discord-bot" description="令牌、频道 ID 与事件告警。" />
   <Card title="订阅" href="/docs/config/subscription" description="订阅服务器、格式与路径。" />
   <Card title="安全" href="/docs/operations/security" description="2FA、IP 限制与加固。" />
 </Cards>

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

@@ -44,7 +44,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 | **Mihomo(明确端点)**        | `/mihomo/`       | `subClashEnable` | 完整 `subClashPath` 配置的别名。                    |
 | **Clash for Windows(旧版)** | `/clash-legacy/` | `subClashEnable` | 仅包含旧 Clash 内核支持的代理类型、传输方式和加密算法。 |
 
-只有使用 **VLESS、VMess、Trojan、Shadowsocks 或 Hysteria2** 的已启用入站才会出现在订阅中,并按其订阅排序索引排列。使用 `Accept: text/html` 头(或 `?html=1`)请求 `subPath` 会返回一个人类可读的信息页面,而非原始内容。
+只有使用 **VLESS、VMess、Trojan、Shadowsocks、WireGuard、AmneziaWG、MTProto、TUIC 或 Hysteria2** 的已启用入站才会出现在订阅中,并按其订阅排序索引排列(TUIC 和 AmneziaWG 包含在原始链接和 Clash/Mihomo 配置中,但在 JSON 端点中被省略;MTProto 包含在原始链接中)。使用 `Accept: text/html` 头(或 `?html=1`)请求 `subPath` 会返回一个人类可读的信息页面,而非原始内容。
 
 Clash Verge Rev、Mihomo 及其他仍在维护的 Mihomo 客户端应使用
 `/mihomo/<sub-id>`。已经停止维护的 Clash for Windows 应使用

+ 2 - 2
docs/content/docs/zh/guide/index.mdx

@@ -27,11 +27,11 @@ flowchart LR
 
 ## 它为你提供什么
 
-- 一个面向所有主流协议的**入站**仪表盘——VLESS、VMess、Trojan、Shadowsocks、WireGuard、Hysteria2、SOCKS、HTTP 以及 Dokodemo-door。
+- 一个面向所有主流协议的**入站**仪表盘——VLESS、VMess、Trojan、Shadowsocks、WireGuard、AmneziaWG、TUIC v5、Hysteria2、SOCKS、HTTP 以及 Dokodemo-door。
 - 一流的 **REALITY** 与 **XTLS-Vision** 支持,带来隐蔽、快速的传输方式。
 - **按客户端**设置的流量配额、到期日期、IP 限制、在线状态,以及一键生成分享链接 / 二维码。
 - 支持 VLESS、Clash/Mihomo 和 JSON 格式的**订阅**。
-- 运维工具:**多节点**管理、**Telegram 机器人**、备份、基于 Fail2ban 的 IP 限制,以及一套有文档说明的 REST API。
+- 运维工具:**多节点**管理、**Telegram 和 Discord 机器人**、备份、基于 Fail2ban 的 IP 限制,以及一套有文档说明的 REST API。
 
 ## 底层原理
 

+ 2 - 2
docs/content/docs/zh/index.mdx

@@ -38,12 +38,12 @@ icon: House
 ## 亮点
 
 - **覆盖所有主流协议** —— VLESS、VMess、Trojan、Shadowsocks、WireGuard、
-  Hysteria2、SOCKS、HTTP 以及 Dokodemo-door。
+  AmneziaWG、TUIC v5、Hysteria2、SOCKS、HTTP 以及 Dokodemo-door。
 - **REALITY 与 XTLS-Vision** —— 现代化、抗审查的传输方式。
 - **细粒度的客户端管理** —— 流量配额、到期日期、IP 限制、分享
   链接以及 QR 码。
 - **订阅** —— 支持 VLESS、Clash/Mihomo 以及 JSON 格式。
-- **运维能力** —— 多节点管理、Telegram 机器人、备份以及 REST API。
+- **运维能力** —— 多节点管理、Telegram 和 Discord 机器人、备份以及 REST API。
 
 <Callout type="info">
   初次接触 Xray?请先阅读 [什么是 3x-ui?](/docs/guide) —— 它解释了面板、Xray-core 与

+ 2 - 6
docs/content/docs/zh/operations/backup-restore.mdx

@@ -28,13 +28,9 @@ cp /etc/x-ui/x-ui.db /root/x-ui-backup-$(date +%F).db
   运行迁移,而不要强行套用旧的数据库结构。
 </Callout>
 
-## Telegram 备份
+## 机器人自动备份(Telegram 与 Discord)
 
-如果你已配置 [Telegram 机器人](/docs/operations/telegram-bot),启用
-**`tgBotBackup`** 即可在周期性报告中附带一份备份(按 `tgRunTime`
-计划执行,默认每天一次)。机器人会将**数据库**与 Xray 的
-**`config.json`** 一并发送到你的管理员聊天,从而让你始终拥有一份服务器之外的副本。
-管理员也可以从机器人的菜单中按需请求备份。
+如果你已配置 [Telegram 机器人](/docs/operations/telegram-bot) 或 [Discord 机器人](/docs/operations/discord-bot),启用 **`tgBotBackup`** 或 **`discordBotBackup`** 即可在周期性报告中附带一份备份(按 `tgRunTime` / `discordRunTime` 计划执行,默认每天一次)。机器人会将**数据库**与 Xray 的 **`config.json`** 一并发送到你的管理员聊天或频道,从而让你始终拥有一份服务器之外的副本。管理员也可以随时从 Telegram 机器人的菜单或使用 Discord 的 `!backup` 命令按需请求备份。
 
 ## SQLite 转储 / 恢复
 

+ 121 - 0
docs/content/docs/zh/operations/discord-bot.mdx

@@ -0,0 +1,121 @@
+---
+title: Discord 机器人
+description: 将 Discord 机器人接入 3x-ui,在指定频道接收实时的面板事件 Embed 告警、周期性健康报告(含数据库备份)以及执行交互式控制命令。
+icon: Bot
+---
+
+3x-ui 提供了完整的 Discord 集成支持:通过事件总线(`EventBus`)实时推送事件告警、通过定时任务发送包含数据库备份的服务器状态报告,以及通过 Discord Gateway 执行交互式管理命令。
+
+<Callout type="info">
+  Discord 实时通知和周期性报告使用出站 HTTPS REST API v10 请求。交互式机器人命令则通过与 Discord Gateway 建立的后台安全 WebSocket 连接实现。
+</Callout>
+
+## 完成配置
+
+<Steps>
+
+<Step>
+### 创建 Discord 应用程序与机器人
+
+1. 打开 [Discord 开发者门户](https://discord.com/developers/applications) 并登录。
+2. 点击右上角的 **New Application**,输入名称(例如 `3x-ui Notifier`)并确认创建。
+3. 在左侧菜单中,进入 **Bot** 标签页。
+4. 点击 **Reset Token**(如果尚未创建机器人则点击 **Add Bot**),并复制生成的 **Bot Token**。请妥善保管该令牌。
+5. 在 **Privileged Gateway Intents** 区域,勾选启用 **Message Content Intent**(机器人读取 `!status` 等前缀命令所必需)。
+</Step>
+
+<Step>
+### 邀请机器人加入你的 Discord 服务器
+
+1. 在开发者门户左侧导航栏中,进入 **OAuth2** → **URL Generator**。
+2. 在 **Scopes** 中勾选 `bot`。
+3. 在下方展开的 **Bot Permissions** 中,勾选以下权限:
+   - **Send Messages**(发送消息)
+   - **Embed Links**(嵌入链接)
+   - **Attach Files**(附加文件 —— 发送数据库备份附件所必需)
+   - **Read Message History**(读取消息历史)
+4. 复制页面底部生成的邀请链接,在浏览器中打开并将机器人添加到你的目标服务器。
+</Step>
+
+<Step>
+### 复制频道 ID
+
+1. 在 Discord 客户端中开启开发者模式:**用户设置** → **高级** → **开发者模式**(开启)。
+2. 右键点击希望接收告警和执行命令的频道,选择**复制频道 ID**(Copy Channel ID)。
+3. 确保机器人拥有该频道的查看和发送消息权限。
+</Step>
+
+<Step>
+### 配置面板
+
+1. 在 3x-ui 面板中,打开**面板设置** → **Discord 机器人**(或直接访问 `/settings#discord`)。
+2. 在**通用**区域:
+   - 开启**启用 Discord 通知**。
+   - 填入你的 **Discord Bot Token** 和 **频道 ID**。
+   - 在**管理员用户 ID**中填入你自己的 Discord 用户数字 ID(右键你的个人头像 → **复制用户 ID**;多个 ID 请用英文逗号分隔)。
+   - 选择偏好的 **Discord 机器人语言**。
+3. 在**通知**区域:
+   - 设置**通知时间**(如 `@daily`、`@weekly` 或自定义 Cron 表达式)。
+   - 如需自动备份,可开启**数据库备份**,定时报告中将自动附带 `x-ui.db` 备份文件。
+   - 勾选需要触发告警的事件类型,并配置 CPU / 内存阈值。
+4. 点击**发送测试通知**以验证连通性。你的 Discord 频道应立刻收到一条测试 Embed 消息。
+5. 点击**保存**应用配置。
+</Step>
+
+</Steps>
+
+## 机器人命令
+
+启用后,机器人将在配置的 Discord 频道内监听命令(同时支持 `!` 和 `/` 前缀)。仅列在**管理员用户 ID**中的用户可以执行命令;来自其他用户的消息将被忽略,若未配置管理员 ID 则关闭命令响应。`!backup` 与定时备份会将数据库文件发送至频道中,因此请务必选择仅管理员可见的频道:
+
+| 命令 | 说明 |
+| ---- | ---- |
+| `!status` | 查看系统负载、内存占用、CPU 使用率、核心状态、TCP/UDP 连接数及当前在线用户。 |
+| `!report` | 立即生成并发送完整的服务器与代理状态报告 Embed。 |
+| `!backup` | 立即导出并发送当前数据库备份文件(`x-ui.db`)与 `config.json`。 |
+| `!usage <email>` | 查询指定客户端的流量用量(上传/下载)、配额上限及到期时间。 |
+| `!inbounds` | 列出所有活动的入站连接、监听端口、协议、已用流量及客户端数量。 |
+| `!restart` | 安全重启 Xray 核心,无需重启整个 Web 面板。 |
+| `!help` | 显示机器人可用命令列表及使用说明。 |
+
+## 事件告警
+
+告警以 Discord Embed 格式发送,带有颜色标识和关键诊断信息:
+
+| 事件类型 | 标识 | 说明 |
+| -------- | ---- | ---- |
+| `xray.crash` | 🔴 红色 | Xray 核心崩溃;包含崩溃原因及时间戳 |
+| `outbound.down` | 🔴 红色 | 出站连通性探测失败 |
+| `outbound.up` | 🟢 绿色 | 出站连通性已恢复 |
+| `node.down` | 🔴 红色 | 远程子节点离线或不可达 |
+| `node.up` | 🟢 绿色 | 远程子节点重新连接且健康 |
+| `cpu.high` | 🟠 橙色 | 服务器 CPU 使用率超过设定阈值(`discordCpu`) |
+| `memory.high` | 🟠 橙色 | 服务器内存使用率超过设定阈值(`discordMemory`) |
+| `login.attempt` | 🟢 / 🔴 | Web 面板登录尝试(包含用户名、客户端 IP 及登录结果) |
+
+<Callout type="warn">
+  登录告警仅包含尝试的用户名及客户端 IP 地址。系统绝不会记录或传输密码明文。
+</Callout>
+
+## 设置参考
+
+| 设置项 | 默认值 | 说明 |
+| ------ | ------ | ---- |
+| `discordBotEnable` | `false` | Discord 机器人与告警总开关。 |
+| `discordBotToken` | _(保密)_ | 从 Discord 开发者门户获取的 Bot Token。 |
+| `discordChannelId` | _(无)_ | 接收消息的目标 Discord 频道 Snowflake ID(17–20 位数字)。 |
+| `discordAdminIds` | _(无)_ | 允许执行命令的 Discord 用户数字 ID(逗号分隔)。留空则禁用命令交互。 |
+| `discordLang` | `en-US` | Discord 机器人消息与报告使用的语言。 |
+| `discordRunTime` | `@daily` | 发送周期性状态报告的 Cron 表达式或预设计划。 |
+| `discordBotBackup` | `false` | 是否在周期性报告中自动附带数据库备份文件(`x-ui.db`)。 |
+| `discordEnabledEvents` | `login.attempt,cpu.high` | 触发通知的事件类型列表(逗号分隔)。 |
+| `discordCpu` | `80` | 触发 CPU 告警的利用率百分比阈值(0–100)。 |
+| `discordMemory` | `80` | 触发内存告警的利用率百分比阈值(0–100)。 |
+
+## 故障排查
+
+- **测试报错 "invalid bot token (401)"**:请确认复制的是开发者门户 **Bot** 标签页中的 Bot Token,而非 Client Secret 或 Application ID。
+- **测试报错 "missing permissions (403)"**:请检查机器人角色在目标频道或对应分类目录中是否拥有 **Send Messages**、**Embed Links** 以及 **Attach Files** 权限。
+- **命令无响应**:请确认你的 Discord 用户 ID 已填入**管理员用户 ID**中。然后检查开发者门户中该机器人的 **Message Content Intent** 是否已开启,并重启面板(若缺少该意图,Discord 会直接关闭连接且不再重试)。
+- **测试报错 "channel not found (404)"**:请检查频道 ID 是否为纯数字,并确认机器人已加入拥有该频道的服务器。
+- **出站代理需求**:若你的服务器所在网络环境访问 Discord 需经过代理,请在面板设置中配置**面板出站代理**(Panel Outbound),Discord 的所有请求将自动经由该代理发出。

+ 1 - 1
docs/content/docs/zh/operations/multi-node.mdx

@@ -25,7 +25,7 @@ icon: Boxes
 | **Inbound sync**  | `all` 入站,或按标签 `selected`。                                     |
 | **Outbound tag**  | 可选地**通过**指定的出站到达节点(出口桥接)。                        |
 
-当你添加或测试节点时,主控会验证其可达性。随后它每隔几秒发送一次**心跳**,更新节点的状态(`online` / `offline`)并发出 `node.up` / `node.down` 事件(参见 [Telegram 机器人](/docs/operations/telegram-bot))。
+当你添加或测试节点时,主控会验证其可达性。随后它每隔几秒发送一次**心跳**,更新节点的状态(`online` / `offline`)并发出 `node.up` / `node.down` 事件(参见 [Telegram 机器人](/docs/operations/telegram-bot) 与 [Discord 机器人](/docs/operations/discord-bot))。
 
 <Callout type="info">
   节点通过每个面板稳定的 GUID 来标识,因此节点在重启后仍能保持其身份。节点本身也可以管理更多节点——主控会将这些以只读的**传递性**子节点形式呈现(Node 1 → Node 2 → Node 3)。

+ 9 - 8
docs/lib/site-i18n.ts

@@ -65,9 +65,9 @@ const en: SiteMessages = {
         'Coordinate multiple servers, managed hosts and external proxies, and serve VLESS / Clash / JSON subscriptions.',
     },
     {
-      title: 'Telegram bot & alerts',
+      title: 'Telegram & Discord bots',
       description:
-        'Built-in Telegram notifications for traffic caps, expiry warnings and system load, plus admin actions.',
+        'Built-in Telegram and Discord notifications for traffic caps, expiry warnings and system load, plus admin actions.',
     },
     {
       title: 'Self-hosted & scriptable',
@@ -116,9 +116,9 @@ const fa: SiteMessages = {
         'هماهنگ‌سازی چند سرور، هاست‌های مدیریت‌شده و پروکسی‌های خارجی، و ارائه‌ی سابسکریپشن‌های VLESS / Clash / JSON.',
     },
     {
-      title: 'ربات Telegram و هشدارها',
+      title: 'ربات‌های Telegram و Discord',
       description:
-        'اعلان‌های داخلیِ Telegram برای سقف ترافیک، هشدار انقضا و بار سیستم، به‌علاوه‌ی کنش‌های مدیریتی.',
+        'اعلان‌های داخلیِ Telegram و Discord برای سقف ترافیک، هشدار انقضا و بار سیستم، به‌علاوه‌ی کنش‌های مدیریتی.',
     },
     {
       title: 'خودمیزبان و قابل‌اسکریپت',
@@ -167,9 +167,9 @@ const ru: SiteMessages = {
         'Координация нескольких серверов, управляемых хостов и внешних прокси, а также выдача подписок VLESS / Clash / JSON.',
     },
     {
-      title: 'Telegram-бот и оповещения',
+      title: 'Telegram- и Discord-боты',
       description:
-        'Встроенные уведомления Telegram о лимитах трафика, истечении срока и нагрузке системы, а также действия администратора.',
+        'Встроенные уведомления Telegram и Discord о лимитах трафика, истечении срока и нагрузке системы, а также действия администратора.',
     },
     {
       title: 'Свой хостинг и скрипты',
@@ -217,8 +217,9 @@ const zh: SiteMessages = {
       description: '协调多台服务器、托管主机和外部代理,并提供 VLESS / Clash / JSON 订阅。',
     },
     {
-      title: 'Telegram 机器人与告警',
-      description: '内置 Telegram 通知,覆盖流量上限、到期提醒和系统负载,并支持管理员操作。',
+      title: 'Telegram 与 Discord 机器人',
+      description:
+        '内置 Telegram 和 Discord 通知,覆盖流量上限、到期提醒和系统负载,并支持管理员操作。',
     },
     {
       title: '自托管且可脚本化',

+ 8 - 0
docs/public/openapi.json

@@ -221,6 +221,9 @@
           "subAnnounce": {
             "type": "string"
           },
+          "subCalendarExpireInclusive": {
+            "type": "boolean"
+          },
           "subCertFile": {
             "type": "string"
           },
@@ -558,6 +561,7 @@
           "smtpTo",
           "smtpUsername",
           "subAnnounce",
+          "subCalendarExpireInclusive",
           "subCertFile",
           "subClashAutoDetect",
           "subClashEnable",
@@ -868,6 +872,9 @@
           "subAnnounce": {
             "type": "string"
           },
+          "subCalendarExpireInclusive": {
+            "type": "boolean"
+          },
           "subCertFile": {
             "type": "string"
           },
@@ -1213,6 +1220,7 @@
           "smtpTo",
           "smtpUsername",
           "subAnnounce",
+          "subCalendarExpireInclusive",
           "subCertFile",
           "subClashAutoDetect",
           "subClashEnable",

+ 8 - 0
frontend/public/openapi.json

@@ -221,6 +221,9 @@
           "subAnnounce": {
             "type": "string"
           },
+          "subCalendarExpireInclusive": {
+            "type": "boolean"
+          },
           "subCertFile": {
             "type": "string"
           },
@@ -558,6 +561,7 @@
           "smtpTo",
           "smtpUsername",
           "subAnnounce",
+          "subCalendarExpireInclusive",
           "subCertFile",
           "subClashAutoDetect",
           "subClashEnable",
@@ -868,6 +872,9 @@
           "subAnnounce": {
             "type": "string"
           },
+          "subCalendarExpireInclusive": {
+            "type": "boolean"
+          },
           "subCertFile": {
             "type": "string"
           },
@@ -1213,6 +1220,7 @@
           "smtpTo",
           "smtpUsername",
           "subAnnounce",
+          "subCalendarExpireInclusive",
           "subCertFile",
           "subClashAutoDetect",
           "subClashEnable",

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

@@ -58,6 +58,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpTo": "",
     "smtpUsername": "",
     "subAnnounce": "",
+    "subCalendarExpireInclusive": false,
     "subCertFile": "",
     "subClashAutoDetect": false,
     "subClashEnable": false,
@@ -213,6 +214,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpTo": "",
     "smtpUsername": "",
     "subAnnounce": "",
+    "subCalendarExpireInclusive": false,
     "subCertFile": "",
     "subClashAutoDetect": false,
     "subClashEnable": false,

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -195,6 +195,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subAnnounce": {
         "type": "string"
       },
+      "subCalendarExpireInclusive": {
+        "type": "boolean"
+      },
       "subCertFile": {
         "type": "string"
       },
@@ -532,6 +535,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpTo",
       "smtpUsername",
       "subAnnounce",
+      "subCalendarExpireInclusive",
       "subCertFile",
       "subClashAutoDetect",
       "subClashEnable",
@@ -842,6 +846,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subAnnounce": {
         "type": "string"
       },
+      "subCalendarExpireInclusive": {
+        "type": "boolean"
+      },
       "subCertFile": {
         "type": "string"
       },
@@ -1187,6 +1194,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpTo",
       "smtpUsername",
       "subAnnounce",
+      "subCalendarExpireInclusive",
       "subCertFile",
       "subClashAutoDetect",
       "subClashEnable",

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

@@ -65,6 +65,7 @@ export interface AllSetting {
   smtpTo: string;
   smtpUsername: string;
   subAnnounce: string;
+  subCalendarExpireInclusive: boolean;
   subCertFile: string;
   subClashAutoDetect: boolean;
   subClashEnable: boolean;
@@ -221,6 +222,7 @@ export interface AllSettingView {
   smtpTo: string;
   smtpUsername: string;
   subAnnounce: string;
+  subCalendarExpireInclusive: boolean;
   subCertFile: string;
   subClashAutoDetect: boolean;
   subClashEnable: boolean;

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

@@ -79,6 +79,7 @@ export const AllSettingSchema = z.object({
   smtpTo: z.string(),
   smtpUsername: z.string(),
   subAnnounce: z.string(),
+  subCalendarExpireInclusive: z.boolean(),
   subCertFile: z.string(),
   subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),
@@ -236,6 +237,7 @@ export const AllSettingViewSchema = z.object({
   smtpTo: z.string(),
   smtpUsername: z.string(),
   subAnnounce: z.string(),
+  subCalendarExpireInclusive: z.boolean(),
   subCertFile: z.string(),
   subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),

+ 50 - 10
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -71,6 +71,24 @@ function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
   );
 }
 
+// Mirrors the order the loader migrates freedom's legacy strategy keys in:
+// root targetStrategy, settings targetStrategy, settings domainStrategy, sockopt.
+export function freedomDomainStrategyFromWire(outbound: {
+  targetStrategy?: unknown;
+  settings?: unknown;
+  streamSettings?: unknown;
+}): OutboundDomainStrategy | '' {
+  const settings = asObject(outbound.settings);
+  const sockopt = asObject(asObject(outbound.streamSettings).sockopt);
+  const root = targetStrategyFromWire(outbound.targetStrategy);
+  const settingsKey = asString(settings.targetStrategy)
+    ? settings.targetStrategy
+    : settings.domainStrategy;
+  const legacy = root && root !== 'AsIs' ? root : targetStrategyFromWire(settingsKey);
+  if (legacy && legacy !== 'AsIs') return legacy;
+  return targetStrategyFromWire(sockopt.domainStrategy);
+}
+
 const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
 
 const SNIFFING_DEFAULT: Sniffing = {
@@ -262,7 +280,10 @@ function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
   };
 }
 
-function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
+function freedomFromWire(
+  raw: Raw,
+  domainStrategy: OutboundDomainStrategy | '',
+): FreedomOutboundFormSettings {
   const fragment = asObject(raw.fragment);
   const noises = asArray(raw.noises).map((n) => {
     const nn = asObject(n);
@@ -306,9 +327,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
   const wireHasFragment =
     raw.fragment != null && typeof raw.fragment === 'object' && Object.keys(fragment).length > 0;
   return {
-    domainStrategy: targetStrategyFromWire(
-      asString(raw.targetStrategy) || asString(raw.domainStrategy),
-    ),
+    domainStrategy,
     redirect: asString(raw.redirect),
     userLevel: asNumber(raw.userLevel, 0),
     proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
@@ -527,6 +546,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);
   const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
+  const freedomStrategy = freedomDomainStrategyFromWire(raw);
   const mux = muxFromWire(raw.mux);
   const hasStream =
     raw.streamSettings &&
@@ -564,7 +584,10 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
       typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
       break;
     case 'freedom':
-      typed = { protocol: 'freedom', settings: freedomFromWire(settings) };
+      typed = {
+        protocol: 'freedom',
+        settings: freedomFromWire(settings, freedomStrategy),
+      };
       break;
     case 'blackhole':
       typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) };
@@ -583,7 +606,9 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
     ...typed,
     tag,
     sendThrough,
-    targetStrategy,
+    // The freedom card owns the strategy for freedom, so the shared root field
+    // stays empty and cannot disagree with what the card is showing.
+    targetStrategy: protocol === 'freedom' ? '' : targetStrategy,
     mux,
     streamSettings,
   };
@@ -717,8 +742,6 @@ function hysteriaToWire(s: HysteriaOutboundFormSettings) {
 }
 
 function freedomToWire(s: FreedomOutboundFormSettings) {
-  // The strategy is emitted under the legacy domainStrategy key: new cores
-  // fall back to it when targetStrategy is absent, old cores only know it.
   // Legacy semantics: emit fragment only when the user actually populated
   // at least one of the four sub-fields. Defaults like packets='1-3' alone
   // are not enough — the modal's Fragment Switch sets all four together.
@@ -727,8 +750,9 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
   const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
   const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
   const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
+  // domainStrategy is absent here on purpose: formValuesToWirePayload hoists it
+  // into streamSettings.sockopt, the only placement freedom resolves with.
   return {
-    domainStrategy: s.domainStrategy || undefined,
     redirect: s.redirect || undefined,
     userLevel: s.userLevel || undefined,
     proxyProtocol: s.proxyProtocol || undefined,
@@ -881,7 +905,9 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     settings,
   };
   if (values.tag) result.tag = values.tag;
-  if (values.targetStrategy) result.targetStrategy = values.targetStrategy;
+  if (values.targetStrategy && values.protocol !== 'freedom') {
+    result.targetStrategy = values.targetStrategy;
+  }
 
   // streamSettings emission gates on canEnableStream — non-stream protocols
   // still emit just `sockopt` if that key is present (legacy behavior).
@@ -894,6 +920,20 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     }
   }
 
+  // Freedom only honours sockopt.domainStrategy; the root and settings keys are
+  // legacy aliases the loader warns about on every start (infra/conf/xray.go).
+  if (values.protocol === 'freedom') {
+    const stream = (result.streamSettings ?? {}) as Raw;
+    const sockopt = asObject(stream.sockopt);
+    const strategy = values.settings.domainStrategy || values.targetStrategy;
+    if (strategy && strategy !== 'AsIs') sockopt.domainStrategy = strategy;
+    else delete sockopt.domainStrategy;
+    if (Object.keys(sockopt).length > 0) stream.sockopt = sockopt;
+    else delete stream.sockopt;
+    if (Object.keys(stream).length > 0) result.streamSettings = stream;
+    else delete result.streamSettings;
+  }
+
   if (values.sendThrough) result.sendThrough = values.sendThrough;
   // mux may be absent when the modal didn't render the Mux switch (non-
   // stream protocols or when isMuxAllowed gated it out). validateFields()

+ 1 - 0
frontend/src/models/setting.ts

@@ -19,6 +19,7 @@ export class AllSetting {
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   subShowIdentityOnAllLinks = false;
   subInfoNodeEnable = false;
+  subCalendarExpireInclusive = false;
   subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
   subTrafficDepletedTemplate =
     '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}';

+ 11 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -197,6 +197,17 @@ export default function SubscriptionGeneralTab({
                 />
               </SettingListItem>
 
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subCalendarExpireInclusive')}
+                description={t('pages.settings.subCalendarExpireInclusiveDesc')}
+              >
+                <Switch
+                  checked={allSetting.subCalendarExpireInclusive}
+                  onChange={(v) => updateSetting({ subCalendarExpireInclusive: v })}
+                />
+              </SettingListItem>
+
               <SettingListItem
                 paddings="small"
                 title={t('pages.settings.subExpiredTemplate')}

+ 3 - 24
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -25,6 +25,7 @@ import {
   MASK_ADDRESS,
   ROUTING_DOMAIN_STRATEGIES,
 } from './constants';
+import { directFreedomStrategy, setDirectFreedomStrategy } from './helpers';
 
 interface BasicsTabProps {
   templateSettings: XraySettingsValue | null;
@@ -109,11 +110,7 @@ export default function BasicsTab({
     });
   }
 
-  const freedomStrategy =
-    (
-      templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')
-        ?.settings as { domainStrategy?: string } | undefined
-    )?.domainStrategy ?? 'AsIs';
+  const freedomStrategy = directFreedomStrategy(templateSettings);
 
   const directFreedomOutbound = templateSettings?.outbounds?.find(
     (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
@@ -186,25 +183,7 @@ export default function BasicsTab({
                 value={freedomStrategy}
                 style={{ width: '100%' }}
                 options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
-                onChange={(next) =>
-                  mutate((tt) => {
-                    if (!tt.outbounds) tt.outbounds = [];
-                    const idx = tt.outbounds.findIndex(
-                      (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
-                    );
-                    if (idx < 0) {
-                      tt.outbounds.push({
-                        protocol: 'freedom',
-                        tag: 'direct',
-                        settings: { domainStrategy: next },
-                      });
-                    } else {
-                      const ob = tt.outbounds[idx];
-                      ob.settings = (ob.settings || {}) as Record<string, unknown>;
-                      (ob.settings as Record<string, unknown>).domainStrategy = next;
-                    }
-                  })
-                }
+                onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
               />
             }
           />

+ 3 - 1
frontend/src/pages/xray/basics/constants.ts

@@ -61,8 +61,10 @@ export const SERVICES_OPTIONS = [
 
 export const directSettings = { tag: 'direct', protocol: 'freedom' };
 export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
+// The strategy rides on sockopt: freedom resolves through the socket layer, and
+// the settings-level alias makes the core warn on every config load.
 export const ipv4Settings = {
   tag: 'IPv4',
   protocol: 'freedom',
-  settings: { domainStrategy: 'UseIPv4' },
+  streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
 };

+ 42 - 0
frontend/src/pages/xray/basics/helpers.ts

@@ -1,6 +1,48 @@
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { freedomDomainStrategyFromWire } from '@/lib/xray/outbound-form-adapter';
 import { blockedSettings, directSettings } from './constants';
 
+// Freedom resolves through the socket layer, so the outbound root and its own
+// settings only hold legacy aliases the core warns about (infra/conf/xray.go).
+const LEGACY_FREEDOM_STRATEGY_KEYS = ['domainStrategy', 'targetStrategy'] as const;
+
+type Outbound = Record<string, unknown>;
+
+function directFreedom(t: XraySettingsValue | null): Outbound | undefined {
+  return t?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct') as
+    | Outbound
+    | undefined;
+}
+
+export function directFreedomStrategy(t: XraySettingsValue | null): string {
+  const outbound = directFreedom(t);
+  if (!outbound) return 'AsIs';
+  return freedomDomainStrategyFromWire(outbound) || 'AsIs';
+}
+
+export function setDirectFreedomStrategy(t: XraySettingsValue, next: string): void {
+  if (!Array.isArray(t.outbounds)) t.outbounds = [];
+  let idx = t.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
+  if (idx < 0) {
+    t.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} } as never);
+    idx = t.outbounds.length - 1;
+  }
+  const ob = t.outbounds[idx] as Outbound;
+  // Drop the legacy placements, or the loader keeps warning and the core keeps
+  // preferring the root key it resets over the sockopt value set here.
+  const settings = (ob.settings ?? {}) as Outbound;
+  for (const key of LEGACY_FREEDOM_STRATEGY_KEYS) delete settings[key];
+  ob.settings = settings;
+  const stream = (ob.streamSettings ?? {}) as Outbound;
+  const sockopt = (stream.sockopt ?? {}) as Outbound;
+  if (next === 'AsIs') delete sockopt.domainStrategy;
+  else sockopt.domainStrategy = next;
+  if (Object.keys(sockopt).length === 0) delete stream.sockopt;
+  else stream.sockopt = sockopt;
+  if (Object.keys(stream).length === 0) delete ob.streamSettings;
+  else ob.streamSettings = stream;
+}
+
 export function ruleGetter(
   t: XraySettingsValue | null,
   outboundTag: string,

+ 15 - 8
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -417,13 +417,17 @@ export default function OutboundFormModal({
                         <Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
                       </FormField>
 
-                      <FormField
-                        label={t('pages.xray.outbound.targetStrategy')}
-                        name="targetStrategy"
-                        tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
-                      >
-                        <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
-                      </FormField>
+                      {/* Freedom's own card owns the strategy — the core migrates this
+                          root key into the same sockopt value, so two knobs would race. */}
+                      {protocol !== 'freedom' && (
+                        <FormField
+                          label={t('pages.xray.outbound.targetStrategy')}
+                          name="targetStrategy"
+                          tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
+                        >
+                          <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
+                        </FormField>
+                      )}
 
                       {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
                       {protocol === 'vmess' && <VmessFields />}
@@ -541,7 +545,10 @@ export default function OutboundFormModal({
                       {((streamAllowed && network) ||
                         !streamAllowed ||
                         protocol === 'wireguard') && (
-                        <SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
+                        <SockoptForm
+                          outboundTags={dialerProxyTags ?? existingTags}
+                          showDomainStrategy={protocol !== 'freedom'}
+                        />
                       )}
 
                       <Controller

+ 24 - 12
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -12,7 +12,17 @@ import {
 
 import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
 
-export default function SockoptForm({ outboundTags = [] }: { outboundTags?: string[] }) {
+interface SockoptFormProps {
+  outboundTags?: string[];
+  showDomainStrategy?: boolean;
+}
+
+// Freedom's own card writes the strategy into this same sockopt key, so it hides
+// this field rather than letting two controls fight over one value.
+export default function SockoptForm({
+  outboundTags = [],
+  showDomainStrategy = true,
+}: SockoptFormProps) {
   const { t } = useTranslation();
   const { control, setValue } = useFormContext();
   const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
@@ -51,17 +61,19 @@ export default function SockoptForm({ outboundTags = [] }: { outboundTags?: stri
               options={dialerProxyOptions}
             />
           </FormField>
-          <FormField
-            label={t('pages.xray.wireguard.domainStrategy')}
-            name={['streamSettings', 'sockopt', 'domainStrategy']}
-          >
-            <Select
-              options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
-                value: v,
-                label: v,
-              }))}
-            />
-          </FormField>
+          {showDomainStrategy && (
+            <FormField
+              label={t('pages.xray.wireguard.domainStrategy')}
+              name={['streamSettings', 'sockopt', 'domainStrategy']}
+            >
+              <Select
+                options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
+                  value: v,
+                  label: v,
+                }))}
+              />
+            </FormField>
+          )}
           <FormField
             label={t('pages.inbounds.form.addressPortStrategy')}
             name={['streamSettings', 'sockopt', 'addressPortStrategy']}

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -23,6 +23,7 @@ export const AllSettingSchema = z
     remarkTemplate: z.string().optional(),
     subShowIdentityOnAllLinks: z.boolean().optional(),
     subInfoNodeEnable: z.boolean().optional(),
+    subCalendarExpireInclusive: z.boolean().optional(),
     subExpiredTemplate: z.string().optional(),
     subTrafficDepletedTemplate: z.string().optional(),
     datepicker: z.enum(['gregorian', 'jalalian']).optional(),

+ 98 - 0
frontend/src/test/basics-freedom-strategy.test.ts

@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest';
+
+import { directFreedomStrategy, setDirectFreedomStrategy } from '@/pages/xray/basics/helpers';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+type Outbound = Record<string, unknown>;
+
+function settingsWithDirect(settings: Outbound, stream?: Outbound): XraySettingsValue {
+  return {
+    outbounds: [
+      {
+        protocol: 'freedom',
+        tag: 'direct',
+        settings,
+        ...(stream ? { streamSettings: stream } : {}),
+      },
+    ],
+  } as unknown as XraySettingsValue;
+}
+
+function directOutbound(t: XraySettingsValue): Outbound {
+  return t.outbounds?.[0] as Outbound;
+}
+
+// This select used to write the deprecated settings key (issue #6482), which is
+// what made the core warn on every load; it has to write sockopt instead.
+describe('BasicsTab freedom strategy', () => {
+  it('writes sockopt and clears both legacy placements', () => {
+    const t = settingsWithDirect({ domainStrategy: 'UseIPv6', targetStrategy: 'UseIP' });
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    const outbound = directOutbound(t);
+    expect(outbound.settings).toEqual({});
+    expect(outbound.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('creates the direct outbound when the config has none', () => {
+    const t = { outbounds: [] } as unknown as XraySettingsValue;
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    expect(directOutbound(t)).toEqual({
+      protocol: 'freedom',
+      tag: 'direct',
+      settings: {},
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
+    });
+  });
+
+  it('keeps other sockopt keys the transport form already set', () => {
+    const t = settingsWithDirect({}, { sockopt: { tcpFastOpen: true } });
+
+    setDirectFreedomStrategy(t, 'ForceIPv4');
+
+    expect(directOutbound(t).streamSettings).toEqual({
+      sockopt: { tcpFastOpen: true, domainStrategy: 'ForceIPv4' },
+    });
+  });
+
+  it('drops the key again when AsIs is chosen', () => {
+    const t = settingsWithDirect({}, { sockopt: { domainStrategy: 'UseIPv4' } });
+
+    setDirectFreedomStrategy(t, 'AsIs');
+
+    expect(directOutbound(t).streamSettings).toBeUndefined();
+  });
+
+  it('shows the value the core will run with: legacy settings outrank sockopt', () => {
+    expect(
+      directFreedomStrategy(
+        settingsWithDirect(
+          { domainStrategy: 'UseIPv6' },
+          { sockopt: { domainStrategy: 'UseIPv4' } },
+        ),
+      ),
+    ).toBe('UseIPv6');
+    expect(directFreedomStrategy(settingsWithDirect({ targetStrategy: 'ForceIPv6' }))).toBe(
+      'ForceIPv6',
+    );
+    expect(directFreedomStrategy(settingsWithDirect({ domainStrategy: 'UseIPv4v6' }))).toBe(
+      'UseIPv4v6',
+    );
+    expect(
+      directFreedomStrategy(settingsWithDirect({}, { sockopt: { domainStrategy: 'UseIPv4' } })),
+    ).toBe('UseIPv4');
+    expect(directFreedomStrategy(settingsWithDirect({}))).toBe('AsIs');
+    expect(directFreedomStrategy(null)).toBe('AsIs');
+  });
+
+  it('does not let an inert AsIs alias mask the sockopt value', () => {
+    expect(
+      directFreedomStrategy(
+        settingsWithDirect({ domainStrategy: 'AsIs' }, { sockopt: { domainStrategy: 'UseIPv4' } }),
+      ),
+    ).toBe('UseIPv4');
+  });
+});

+ 27 - 0
frontend/src/test/calendar-expire-setting.test.tsx

@@ -0,0 +1,27 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
+import { renderWithProviders } from './test-utils';
+
+describe('calendar expiry presentation setting', () => {
+  it('is off by default and updates only the presentation option', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Information/ }));
+    const toggle = screen.getByRole('switch', { name: 'Month-end subscription expiry display' });
+    expect(toggle.getAttribute('aria-checked')).toBe('false');
+    expect(updateSetting).not.toHaveBeenCalled();
+    fireEvent.click(toggle);
+
+    expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subCalendarExpireInclusive: true });
+  });
+});

+ 154 - 0
frontend/src/test/freedom-strategy-placement.test.ts

@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest';
+
+import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
+
+// A freedom outbound resolves through sockopt.domainStrategy, and the core warns
+// on every load for both legacy placements it migrates there (infra/conf/xray.go).
+describe('freedom domain strategy placement', () => {
+  it('emits the freedom card strategy into sockopt instead of settings', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'UseIPv4' },
+      }),
+    );
+
+    expect((wire.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+    expect(wire.targetStrategy).toBeUndefined();
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('migrates a legacy settings key into sockopt on the next emit', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'UseIPv6' },
+      }),
+    );
+
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv6' } });
+    expect((wire.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+  });
+
+  it('migrates a legacy outbound-root targetStrategy into sockopt', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        targetStrategy: 'ForceIPv4',
+        settings: {},
+      }),
+    );
+
+    expect(wire.targetStrategy).toBeUndefined();
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'ForceIPv4' } });
+  });
+
+  it('reads the sockopt strategy back into the freedom card', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4v6' } },
+      settings: {},
+    });
+
+    expect((values.settings as { domainStrategy?: string }).domainStrategy).toBe('UseIPv4v6');
+  });
+
+  it('keeps the freedom card empty rather than showing a stale legacy value twice', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      targetStrategy: 'UseIPv4',
+      settings: {},
+    });
+
+    expect(values.targetStrategy).toBe('');
+    expect((values.settings as { domainStrategy?: string }).domainStrategy).toBe('UseIPv4');
+  });
+
+  it('shows the strategy the core will run with when a legacy key outranks sockopt', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      targetStrategy: 'UseIPv4',
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv6' } },
+      settings: {},
+    });
+
+    expect((values.settings as { domainStrategy?: string }).domainStrategy).toBe('UseIPv4');
+    const wire = formValuesToWirePayload(values);
+    expect(wire.targetStrategy).toBeUndefined();
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('drops the sockopt key when the card is cleared', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4', tcpFastOpen: true } },
+      settings: {},
+    });
+    (values.settings as { domainStrategy?: string }).domainStrategy = '';
+
+    const wire = formValuesToWirePayload(values);
+
+    expect(wire.streamSettings).toEqual({ sockopt: { tcpFastOpen: true } });
+  });
+
+  it('normalizes the sockopt spelling the core matches case-insensitively', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        streamSettings: { sockopt: { domainStrategy: 'useipv4v6' } },
+        settings: {},
+      }),
+    );
+
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4v6' } });
+  });
+
+  it('leaves AsIs out of the wire entirely', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'AsIs' },
+      }),
+    );
+
+    expect(wire.streamSettings).toBeUndefined();
+    expect((wire.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+  });
+
+  it('merges into a sockopt the transport form already carries', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'UseIPv4' },
+        streamSettings: { sockopt: { tcpFastOpen: true } },
+      }),
+    );
+
+    expect(wire.streamSettings).toEqual({
+      sockopt: { tcpFastOpen: true, domainStrategy: 'UseIPv4' },
+    });
+  });
+
+  it('still emits the root targetStrategy for protocols that use it there', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'vless',
+        tag: 'proxy',
+        targetStrategy: 'UseIPv4',
+        settings: { address: 'example.com', port: 443, id: 'x', encryption: 'none' },
+      }),
+    );
+
+    expect(wire.targetStrategy).toBe('UseIPv4');
+  });
+});

+ 8 - 4
frontend/src/test/outbound-form-adapter.test.ts

@@ -376,8 +376,9 @@ describe('outbound-form-adapter: round-trip', () => {
         },
       }),
     );
+    // The strategy no longer rides in settings; see freedom-strategy-placement.test.ts.
+    expect(filled.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
     expect(filled.settings).toMatchObject({
-      domainStrategy: 'UseIPv4',
       redirect: '1.1.1.1',
       userLevel: 3,
       proxyProtocol: 2,
@@ -556,7 +557,7 @@ describe('outbound-form-adapter: targetStrategy', () => {
 
   it('normalizes wire case to the canonical spelling (core matches case-insensitively)', () => {
     const form = rawOutboundToFormValues({
-      protocol: 'freedom',
+      protocol: 'vless',
       settings: {},
       targetStrategy: 'useipv4v6',
     });
@@ -582,7 +583,7 @@ describe('outbound-form-adapter: targetStrategy', () => {
     expect(invalid).not.toHaveProperty('targetStrategy');
   });
 
-  it('freedom prefers settings.targetStrategy over domainStrategy and emits the legacy key', () => {
+  it('freedom prefers settings.targetStrategy over domainStrategy and moves it to sockopt', () => {
     const form = rawOutboundToFormValues({
       protocol: 'freedom',
       settings: { targetStrategy: 'UseIPv6', domainStrategy: 'UseIPv4' },
@@ -591,8 +592,11 @@ describe('outbound-form-adapter: targetStrategy', () => {
       expect(form.settings.domainStrategy).toBe('UseIPv6');
     }
     const back = formValuesToWirePayload(form);
-    expect(back.settings).toMatchObject({ domainStrategy: 'UseIPv6' });
+    // Neither legacy key may survive: the core warns about both, and sockopt is
+    // the only placement freedom resolves with.
+    expect(back.settings).not.toHaveProperty('domainStrategy');
     expect(back.settings).not.toHaveProperty('targetStrategy');
+    expect(back.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv6' } });
   });
 });
 

+ 48 - 0
frontend/src/test/outbound-form-modal.test.tsx

@@ -21,6 +21,16 @@ function renderModal(outbound: Record<string, unknown> | null = null) {
   );
 }
 
+function toggleSockoptsSwitch() {
+  const item = Array.from(document.querySelectorAll('.ant-form-item')).find(
+    (el) =>
+      (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === 'Sockopts',
+  );
+  const control = item?.querySelector('.ant-switch');
+  if (!control) throw new Error('Sockopts switch not found');
+  fireEvent.click(control);
+}
+
 describe('OutboundFormModal', () => {
   it('renders add mode without crashing', () => {
     renderModal(null);
@@ -57,6 +67,44 @@ describe('OutboundFormModal', () => {
     }
   }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
 
+  // Freedom's card and the Transport tab's Sockopts block both write
+  // sockopt.domainStrategy, so freedom must show only one control for it.
+  it('hides the Transport sockopt strategy for freedom', () => {
+    renderModal({ protocol: 'freedom', tag: 'direct', settings: {} });
+    toggleSockoptsSwitch();
+
+    expect(fieldLabels()).toContain('Sockopts');
+    expect(fieldLabels()).not.toContain('Domain Strategy');
+    expect(fieldLabels()).toContain('Strategy');
+  });
+
+  it('keeps the Transport sockopt strategy for protocols without a card field', () => {
+    renderModal({ protocol: 'vless', tag: 'proxy', settings: {} });
+    toggleSockoptsSwitch();
+
+    expect(fieldLabels()).toContain('Domain Strategy');
+  });
+
+  // The core migrates freedom's outbound-root targetStrategy into the very same
+  // sockopt.domainStrategy the card writes, so the modal must not offer both.
+  it('offers freedom one strategy knob and other protocols the root one', async () => {
+    renderModal(null);
+
+    chooseSelectOption('protocol', 'freedom');
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+    const freedomLabels = fieldLabels();
+    expect(freedomLabels).toContain('Strategy');
+    expect(freedomLabels).not.toContain('Target Strategy');
+
+    chooseSelectOption('protocol', 'vless');
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+    expect(fieldLabels()).toContain('Target Strategy');
+  });
+
   it('saves a vless reverse outbound while reverse sniffing stays disabled', async () => {
     const onConfirm = vi.fn();
     renderWithProviders(

+ 14 - 1
internal/crypto/nodetoken/nodetoken_test.go

@@ -96,7 +96,7 @@ func TestEncryptedNeverFallsBackToPlaintext(t *testing.T) {
 	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
 	enc, _ := c.Encrypt(1, "tok")
 	// Corrupt the ciphertext body — must error, never return raw bytes.
-	bad := enc[:len(enc)-2] + "AA"
+	bad := flipLastCiphertextBit(t, enc)
 	if _, err := c.Decrypt(1, bad); err == nil {
 		t.Fatal("corrupted ciphertext must fail, not fall back to plaintext")
 	}
@@ -108,6 +108,19 @@ func TestEncryptedNeverFallsBackToPlaintext(t *testing.T) {
 	}
 }
 
+// flipLastCiphertextBit rewrites the body through its decoded bytes, because
+// editing the trailing base64 characters can leave those bytes untouched.
+func flipLastCiphertextBit(t *testing.T, stored string) string {
+	t.Helper()
+	cut := strings.LastIndex(stored, ":") + 1
+	blob, err := base64.RawURLEncoding.DecodeString(stored[cut:])
+	if err != nil {
+		t.Fatalf("decode ciphertext body: %v", err)
+	}
+	blob[len(blob)-1] ^= 0x01
+	return stored[:cut] + base64.RawURLEncoding.EncodeToString(blob)
+}
+
 func TestEncryptionMarkerPassesThroughWhenDisabled(t *testing.T) {
 	c, _ := NewCodec(ModeOff, nil)
 	stored := "enc:v1:not-ciphertext"

+ 275 - 1
internal/database/db.go

@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -1365,6 +1365,18 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "FreedomDomainStrategyFix") {
+		if err := migrateFreedomDomainStrategy(); err != nil {
+			return err
+		}
+	}
+
+	if !slices.Contains(seedersHistory, "DNSOutboundLegacyKeysFix") {
+		if err := migrateDNSOutboundLegacyKeys(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
 		if err := seedNodeInboundsAdopted(); err != nil {
 			return err
@@ -1652,6 +1664,268 @@ func outboundSockopt(obj map[string]any, create bool) map[string]any {
 	return sockopt
 }
 
+func migrateFreedomDomainStrategy() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteFreedomDomainStrategy(setting.Value)
+	if rErr != nil {
+		log.Printf("FreedomDomainStrategyFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
+	})
+}
+
+// rewriteFreedomDomainStrategy moves a freedom outbound's legacy strategy keys
+// into sockopt.domainStrategy, the placement the core's deprecation warning names.
+func rewriteFreedomDomainStrategy(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+			continue
+		}
+		settings, hasSettings := obj["settings"].(map[string]any)
+		_, hasRoot := obj["targetStrategy"]
+		_, hasSettingsTarget := settings["targetStrategy"]
+		_, hasSettingsDomain := settings["domainStrategy"]
+		if !hasRoot && !hasSettingsTarget && !hasSettingsDomain {
+			continue
+		}
+		strategy := freedomMigratedStrategy(obj, settings)
+		delete(obj, "targetStrategy")
+		if hasSettings {
+			delete(settings, "targetStrategy")
+			delete(settings, "domainStrategy")
+		}
+		if strategy != "" {
+			outboundSockopt(obj, true)["domainStrategy"] = strategy
+		}
+		changed = true
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+// freedomMigratedStrategy clones the core's own resolution order for a freedom
+// outbound (infra/conf/freedom.go), returning "" when none of them holds one.
+func freedomMigratedStrategy(obj, settings map[string]any) string {
+	if s, ok := freedomStrategyValue(obj["targetStrategy"]); ok && !strings.EqualFold(s, "asis") {
+		return s
+	}
+	legacy := settings["targetStrategy"]
+	if s, ok := legacy.(string); !ok || s == "" {
+		legacy = settings["domainStrategy"]
+	}
+	if s, ok := freedomStrategyValue(legacy); ok && !strings.EqualFold(s, "asis") {
+		return s
+	}
+	return ""
+}
+
+// freedomStrategyValue reports a strategy the core accepts -- anything else is a
+// hard load error in freedom and sockopt alike, so it cannot be migrated.
+func freedomStrategyValue(value any) (string, bool) {
+	s, ok := value.(string)
+	if !ok || s == "" {
+		return "", false
+	}
+	if !freedomDomainStrategies[strings.ToLower(s)] {
+		return "", false
+	}
+	return s, true
+}
+
+var freedomDomainStrategies = map[string]bool{
+	"asis": true, "useip": true, "useipv4": true, "useipv6": true,
+	"useipv4v6": true, "useipv6v4": true, "forceip": true, "forceipv4": true,
+	"forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
+}
+
+// migrateDNSOutboundLegacyKeys rewrites stored dns outbounds once, because the
+// core logs nonIPQuery/blockTypes as deprecated on every config load.
+func migrateDNSOutboundLegacyKeys() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundLegacyKeysFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteDNSOutboundLegacyKeys(setting.Value)
+	if rErr != nil {
+		log.Printf("DNSOutboundLegacyKeysFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundLegacyKeysFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundLegacyKeysFix"}).Error
+	})
+}
+
+// rewriteDNSOutboundLegacyKeys turns a dns outbound's legacy nonIPQuery and
+// blockTypes into rules, in the order the core's legacy builder used.
+func rewriteDNSOutboundLegacyKeys(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
+			continue
+		}
+		settings, _ := obj["settings"].(map[string]any)
+		if settings == nil {
+			continue
+		}
+		nonIPQuery, hasMode := settings["nonIPQuery"]
+		blockTypes, hasTypes := settings["blockTypes"]
+		// JSON null is absent to the core, which decides on nil pointers.
+		hasMode = hasMode && nonIPQuery != nil
+		hasTypes = hasTypes && blockTypes != nil
+		if !hasMode && !hasTypes {
+			continue
+		}
+		// The core refuses legacy keys next to real rules, so existing rules win.
+		if rules, hasRules := settings["rules"]; !hasRules || rules == nil {
+			settings["rules"] = legacyDNSOutboundRules(dnsNonIPQueryMode(nonIPQuery), legacyDNSBlockTypes(blockTypes))
+		}
+		delete(settings, "nonIPQuery")
+		delete(settings, "blockTypes")
+		changed = true
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+// dnsNonIPQueryMode reports the mode the core resolved: everything but drop and
+// skip meant reject, and any other value never loaded in the first place.
+func dnsNonIPQueryMode(value any) string {
+	mode, _ := value.(string)
+	mode = strings.ToLower(strings.TrimSpace(mode))
+	if mode == "drop" || mode == "skip" {
+		return mode
+	}
+	return "reject"
+}
+
+// legacyDNSBlockTypes accepts every shape the old card could save: a list, a
+// bare number, or a comma-separated string, minus the qTypes the core rejects.
+func legacyDNSBlockTypes(value any) []int {
+	items, ok := value.([]any)
+	if !ok && value != nil {
+		items = []any{value}
+	}
+	var out []int
+	for _, item := range items {
+		for _, part := range strings.Split(fmt.Sprint(item), ",") {
+			qType, err := strconv.Atoi(strings.TrimSpace(part))
+			if err != nil || qType < 0 || qType > 65535 {
+				continue
+			}
+			out = append(out, qType)
+		}
+	}
+	return out
+}
+
+// legacyDNSOutboundRules mirrors the core's own legacy dns policy: the blocked
+// qTypes, then the hijack, then the mode's answer for everything else.
+func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
+	rules := make([]any, 0, 3)
+	if len(blockTypes) > 0 {
+		rule := map[string]any{"action": "drop", "qType": dnsQTypeValue(blockTypes)}
+		if mode == "reject" {
+			rule["action"] = "return"
+			rule["rCode"] = 5
+		}
+		rules = append(rules, rule)
+	}
+	rules = append(rules, map[string]any{"action": "hijack", "qType": "1,28"})
+	fallback := map[string]any{"action": "direct"}
+	switch mode {
+	case "reject":
+		fallback["action"] = "return"
+		fallback["rCode"] = 5
+	case "drop":
+		fallback["action"] = "drop"
+	}
+	return append(rules, fallback)
+}
+
+// dnsQTypeValue keeps a lone qType a number, the way the core marshals one.
+func dnsQTypeValue(blockTypes []int) any {
+	if len(blockTypes) == 1 {
+		return blockTypes[0]
+	}
+	parts := make([]string, 0, len(blockTypes))
+	for _, qType := range blockTypes {
+		parts = append(parts, strconv.Itoa(qType))
+	}
+	return strings.Join(parts, ",")
+}
+
 func normalizeSettingPaths() error {
 	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
 	var rows []model.Setting

+ 375 - 0
internal/database/dns_outbound_legacy_keys_migration_test.go

@@ -0,0 +1,375 @@
+package database
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	corelog "github.com/xtls/xray-core/common/log"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRewriteDNSOutboundLegacyKeys(t *testing.T) {
+	tests := []struct {
+		name         string
+		raw          string
+		wantChanged  bool
+		wantOutbound map[string]any
+	}{
+		{
+			name:        "reject keeps the blocked qTypes and answers rCode 5",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"rewriteNetwork":"udp","rewriteAddress":"8.8.8.8","rewritePort":53,"nonIPQuery":"reject","blockTypes":[65,28]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rewriteNetwork": "udp", "rewriteAddress": "8.8.8.8", "rewritePort": float64(53),
+					"rules": []any{
+						map[string]any{"action": "return", "qType": "65,28", "rCode": float64(5)},
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "return", "rCode": float64(5)},
+					},
+				},
+			},
+		},
+		{
+			name:        "drop with no blocked qTypes keeps only the hijack and the answer",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "drop"},
+					},
+				},
+			},
+		},
+		{
+			name:        "skip passes everything else through and keeps a lone qType a number",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"skip","blockTypes":[28]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{
+						map[string]any{"action": "drop", "qType": float64(28)},
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "direct"},
+					},
+				},
+			},
+		},
+		{
+			name:        "the num field could hold a bare number or a string",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"reject","blockTypes":"65, 28"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{
+						map[string]any{"action": "return", "qType": "65,28", "rCode": float64(5)},
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "return", "rCode": float64(5)},
+					},
+				},
+			},
+		},
+		{
+			name:        "a missing mode answered as reject, the core's default",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"blockTypes":[28]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{
+						map[string]any{"action": "return", "qType": float64(28), "rCode": float64(5)},
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "return", "rCode": float64(5)},
+					},
+				},
+			},
+		},
+		{
+			name:        "existing rules win, because the core refuses the mix",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":[{"action":"hijack","qType":1}]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{map[string]any{"action": "hijack", "qType": float64(1)}},
+				},
+			},
+		},
+		{
+			name:        "a null legacy pair is not a legacy config, as in the core",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":null,"blockTypes":null}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{"nonIPQuery": nil, "blockTypes": nil},
+			},
+		},
+		{
+			name:        "null rules leave the legacy pair authoritative",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":null}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{
+						map[string]any{"action": "drop", "qType": float64(28)},
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "drop"},
+					},
+				},
+			},
+		},
+		{
+			name:        "the core lowercases the protocol id it dispatches on",
+			raw:         `{"outbounds":[{"protocol":"DNS","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "DNS", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{
+						map[string]any{"action": "hijack", "qType": "1,28"},
+						map[string]any{"action": "drop"},
+					},
+				},
+			},
+		},
+		{
+			name:        "a dns outbound already on rules is left alone",
+			raw:         `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"rules":[{"action":"hijack","qType":1}]}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "dns", "tag": "dns-out",
+				"settings": map[string]any{
+					"rules": []any{map[string]any{"action": "hijack", "qType": float64(1)}},
+				},
+			},
+		},
+		{
+			name:        "the same key names on another protocol are left alone",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"nonIPQuery":"drop","blockTypes":[28]}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct",
+				"settings": map[string]any{"nonIPQuery": "drop", "blockTypes": []any{float64(28)}},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteDNSOutboundLegacyKeys(tc.raw)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			var cfg struct {
+				Outbounds []map[string]any `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
+				t.Fatalf("rewritten template is not JSON: %v", err)
+			}
+			if len(cfg.Outbounds) != 1 {
+				t.Fatalf("got %d outbounds, want 1", len(cfg.Outbounds))
+			}
+			got, _ := json.Marshal(cfg.Outbounds[0])
+			want, _ := json.Marshal(tc.wantOutbound)
+			if string(got) != string(want) {
+				t.Fatalf("outbound = %s, want %s", got, want)
+			}
+		})
+	}
+}
+
+type dnsCoreLogCapture struct{ msgs []string }
+
+func (c *dnsCoreLogCapture) Handle(msg corelog.Message) { c.msgs = append(c.msgs, msg.String()) }
+
+func (c *dnsCoreLogCapture) has(sub string) bool {
+	return strings.Contains(strings.Join(c.msgs, "\n"), sub)
+}
+
+type dnsDiscardLogHandler struct{}
+
+func (dnsDiscardLogHandler) Handle(corelog.Message) {}
+
+func captureDNSCoreLogs(t *testing.T) *dnsCoreLogCapture {
+	t.Helper()
+	capture := new(dnsCoreLogCapture)
+	corelog.RegisterHandler(capture)
+	t.Cleanup(func() { corelog.RegisterHandler(dnsDiscardLogHandler{}) })
+	return capture
+}
+
+// Drives the real core: the legacy keys warn on every load, rules next to them
+// are refused outright, and it reads JSON null the way this rewrite has to.
+func TestRewriteDNSOutboundLegacyKeysSatisfiesCore(t *testing.T) {
+	for _, tc := range []struct {
+		name              string
+		raw               string
+		wantLoadError     bool
+		wantLegacyWarning bool
+		wantChanged       bool
+	}{
+		{
+			name:              "deprecated keys",
+			raw:               `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"reject","blockTypes":[65,28]}}`,
+			wantLegacyWarning: true,
+			wantChanged:       true,
+		},
+		{
+			name:          "deprecated keys next to rules",
+			raw:           `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":[{"action":"hijack","qType":1}]}}`,
+			wantLoadError: true,
+			wantChanged:   true,
+		},
+		{
+			name: "a null legacy pair warns about nothing and builds no policy",
+			raw:  `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":null,"blockTypes":null}}`,
+		},
+		{
+			name:              "null rules keep the legacy pair in charge, and it warns",
+			raw:               `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":null}}`,
+			wantLegacyWarning: true,
+			wantChanged:       true,
+		},
+		{
+			name:              "an upper-case protocol id is a dns outbound to the core",
+			raw:               `{"protocol":"DNS","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[]}}`,
+			wantLegacyWarning: true,
+			wantChanged:       true,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			capture := captureDNSCoreLogs(t)
+
+			if err := xray.ValidateOutboundConfig([]byte(tc.raw)); (err != nil) != tc.wantLoadError {
+				t.Fatalf("legacy outbound load error = %v, want error: %v", err, tc.wantLoadError)
+			}
+			if capture.has("nonIPQuery") != tc.wantLegacyWarning {
+				t.Fatalf("legacy warning = %v, want %v: %v", capture.has("nonIPQuery"), tc.wantLegacyWarning, capture.msgs)
+			}
+
+			updated, changed, err := rewriteDNSOutboundLegacyKeys(`{"outbounds":[` + tc.raw + `]}`)
+			if err != nil || changed != tc.wantChanged {
+				t.Fatalf("rewrite: changed=%v want %v err=%v", changed, tc.wantChanged, err)
+			}
+			if !changed {
+				if !strings.Contains(updated, tc.raw) {
+					t.Fatalf("unchanged outbound was rewritten: %s", updated)
+				}
+				return
+			}
+			var after struct {
+				Outbounds []json.RawMessage `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &after); err != nil {
+				t.Fatal(err)
+			}
+
+			capture.msgs = nil
+			if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
+				t.Fatalf("xray-core refused the rewritten outbound: %v", err)
+			}
+			if capture.has("nonIPQuery") {
+				t.Fatalf("rewritten outbound still warns on load: %v", capture.msgs)
+			}
+		})
+	}
+}
+
+func TestRewriteDNSOutboundLegacyKeysInvalidJSON(t *testing.T) {
+	_, changed, err := rewriteDNSOutboundLegacyKeys("{not json")
+	if err == nil {
+		t.Fatal("expected an error for invalid JSON")
+	}
+	if changed {
+		t.Fatal("invalid JSON must not report a change")
+	}
+}
+
+func TestMigrateDNSOutboundLegacyKeysRewritesStoredTemplate(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	// A CGO_ENABLED=0 build links a stubbed driver, so this test needs the same
+	// C compiler the rest of the package's DB tests do.
+	if err := InitDB(config.GetDBPath()); err != nil {
+		if strings.Contains(err.Error(), "CGO_ENABLED=0") {
+			t.Skipf("sqlite needs cgo: %v", err)
+		}
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	legacy := `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28]}}]}`
+	seedDNSOutboundTemplate(t, legacy)
+	if err := db.Where("seeder_name = ?", "DNSOutboundLegacyKeysFix").
+		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+
+	if err := migrateDNSOutboundLegacyKeys(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	got := storedDNSOutboundTemplate(t)
+	var cfg struct {
+		Outbounds []map[string]any `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(got), &cfg); err != nil {
+		t.Fatalf("stored template is not JSON: %v", err)
+	}
+	if len(cfg.Outbounds) != 1 {
+		t.Fatalf("stored outbounds = %d, want 1", len(cfg.Outbounds))
+	}
+	settings, _ := cfg.Outbounds[0]["settings"].(map[string]any)
+	if _, present := settings["nonIPQuery"]; present {
+		t.Errorf("stored outbound kept nonIPQuery: %s", got)
+	}
+	if _, present := settings["blockTypes"]; present {
+		t.Errorf("stored outbound kept blockTypes: %s", got)
+	}
+	rules, _ := settings["rules"].([]any)
+	if len(rules) != 3 {
+		t.Fatalf("stored rules = %s, want the three legacy rules in %s", settings["rules"], got)
+	}
+
+	// The history gate is what keeps a hand-edited template from being rewritten
+	// again on every restart, so run the real seeder list over a fresh legacy one.
+	seedDNSOutboundTemplate(t, legacy)
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+	if got := storedDNSOutboundTemplate(t); got != legacy {
+		t.Errorf("a completed seeder rewrote the template again: %s", got)
+	}
+}
+
+func seedDNSOutboundTemplate(t *testing.T, value string) {
+	t.Helper()
+	if err := db.Where("key = ?", "xrayTemplateConfig").Delete(&model.Setting{}).Error; err != nil {
+		t.Fatalf("clear template: %v", err)
+	}
+	if err := db.Create(&model.Setting{Key: "xrayTemplateConfig", Value: value}).Error; err != nil {
+		t.Fatalf("seed template: %v", err)
+	}
+}
+
+func storedDNSOutboundTemplate(t *testing.T) string {
+	t.Helper()
+	var setting model.Setting
+	if err := db.Where("key = ?", "xrayTemplateConfig").First(&setting).Error; err != nil {
+		t.Fatalf("reload template: %v", err)
+	}
+	return setting.Value
+}

+ 295 - 0
internal/database/freedom_domain_strategy_migration_test.go

@@ -0,0 +1,295 @@
+package database
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	corelog "github.com/xtls/xray-core/common/log"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRewriteFreedomDomainStrategy(t *testing.T) {
+	tests := []struct {
+		name         string
+		raw          string
+		wantChanged  bool
+		wantOutbound map[string]any
+	}{
+		{
+			name:        "the deprecated settings key moves to sockopt",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct",
+				"settings":       map[string]any{"finalRules": []any{map[string]any{"action": "allow"}}},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv4"}},
+			},
+		},
+		{
+			name:        "the outbound-root targetStrategy moves to sockopt and is dropped",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "ForceIPv6"}},
+			},
+		},
+		{
+			name:        "the root key wins over the settings key, as in the core",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","targetStrategy":"UseIPv4","settings":{"domainStrategy":"UseIPv6"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv4"}},
+			},
+		},
+		{
+			name:        "the settings targetStrategy wins over domainStrategy",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"targetStrategy":"UseIPv6","domainStrategy":"UseIPv4"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv6"}},
+			},
+		},
+		{
+			name:        "an AsIs alias is dropped and leaves the sockopt value alone",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"AsIs"},"streamSettings":{"sockopt":{"domainStrategy":"UseIPv6","tcpFastOpen":true}}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv6", "tcpFastOpen": true}},
+			},
+		},
+		{
+			name:        "the existing sockopt spelling is preserved",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"useipv4v6"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "useipv4v6"}},
+			},
+		},
+		{
+			name:        "a strategy the core refuses is dropped rather than moved",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv5"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+			},
+		},
+		{
+			name:        "other protocols keep their outbound-root targetStrategy",
+			raw:         `{"outbounds":[{"protocol":"vless","tag":"proxy","targetStrategy":"UseIPv4","settings":{}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "vless", "tag": "proxy", "targetStrategy": "UseIPv4",
+				"settings": map[string]any{},
+			},
+		},
+		{
+			name:        "a freedom outbound without a strategy is left untouched",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[{"action":"allow"}]}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct",
+				"settings": map[string]any{"finalRules": []any{map[string]any{"action": "allow"}}},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteFreedomDomainStrategy(tc.raw)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			var cfg struct {
+				Outbounds []map[string]any `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
+				t.Fatalf("rewritten template is not JSON: %v", err)
+			}
+			if len(cfg.Outbounds) != 1 {
+				t.Fatalf("got %d outbounds, want 1", len(cfg.Outbounds))
+			}
+			got, _ := json.Marshal(cfg.Outbounds[0])
+			want, _ := json.Marshal(tc.wantOutbound)
+			if string(got) != string(want) {
+				t.Fatalf("outbound = %s, want %s", got, want)
+			}
+		})
+	}
+}
+
+type coreLogCapture struct{ msgs []string }
+
+func (c *coreLogCapture) Handle(msg corelog.Message) { c.msgs = append(c.msgs, msg.String()) }
+
+func (c *coreLogCapture) has(sub string) bool {
+	return strings.Contains(strings.Join(c.msgs, "\n"), sub)
+}
+
+type discardLogHandler struct{}
+
+func (discardLogHandler) Handle(corelog.Message) {}
+
+// captureCoreLogs takes over the vendored core's log sink for the duration of
+// one test, which is the only way to observe a config-load warning.
+func captureCoreLogs(t *testing.T) *coreLogCapture {
+	t.Helper()
+	capture := new(coreLogCapture)
+	corelog.RegisterHandler(capture)
+	t.Cleanup(func() { corelog.RegisterHandler(discardLogHandler{}) })
+	return capture
+}
+
+// Drives the real core: a rewrite that dropped the value instead of moving it
+// would leave the config warning on every load and fail here.
+func TestRewriteFreedomDomainStrategySatisfiesCore(t *testing.T) {
+	for _, tc := range []struct {
+		name      string
+		raw       string
+		wantValue string
+	}{
+		{
+			name:      "deprecated settings key",
+			raw:       `{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}`,
+			wantValue: `"domainStrategy": "UseIPv4"`,
+		},
+		{
+			name:      "outbound-root targetStrategy",
+			raw:       `{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}`,
+			wantValue: `"domainStrategy": "ForceIPv6"`,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			capture := captureCoreLogs(t)
+
+			if err := xray.ValidateOutboundConfig([]byte(tc.raw)); err != nil {
+				t.Fatalf("xray-core must accept the legacy outbound: %v", err)
+			}
+			if !capture.has("sockopt.domainStrategy") {
+				t.Fatal("expected the core to warn about the legacy strategy placement")
+			}
+
+			updated, changed, err := rewriteFreedomDomainStrategy(
+				`{"outbounds":[` + tc.raw + `]}`,
+			)
+			if err != nil || !changed {
+				t.Fatalf("rewrite: changed=%v err=%v", changed, err)
+			}
+			var after struct {
+				Outbounds []json.RawMessage `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &after); err != nil {
+				t.Fatal(err)
+			}
+			if !strings.Contains(string(after.Outbounds[0]), tc.wantValue) {
+				t.Fatalf("rewritten outbound = %s, want it to carry %s", after.Outbounds[0], tc.wantValue)
+			}
+
+			capture.msgs = nil
+			if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
+				t.Fatalf("xray-core refused the rewritten outbound: %v", err)
+			}
+			if capture.has("sockopt.domainStrategy") {
+				t.Fatalf("rewritten outbound still warns on load: %v", capture.msgs)
+			}
+		})
+	}
+}
+
+func TestRewriteFreedomDomainStrategyInvalidJSON(t *testing.T) {
+	_, changed, err := rewriteFreedomDomainStrategy("{not json")
+	if err == nil {
+		t.Fatal("expected an error for invalid JSON")
+	}
+	if changed {
+		t.Fatal("invalid JSON must not report a change")
+	}
+}
+
+func TestMigrateFreedomDomainStrategyRewritesStoredTemplate(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	// A CGO_ENABLED=0 build links a stubbed driver, so this test needs the same
+	// C compiler the rest of the package's DB tests do.
+	if err := InitDB(config.GetDBPath()); err != nil {
+		if strings.Contains(err.Error(), "CGO_ENABLED=0") {
+			t.Skipf("sqlite needs cgo: %v", err)
+		}
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	legacy := `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4"}}]}`
+	seedTemplate(t, legacy)
+	if err := db.Where("seeder_name = ?", "FreedomDomainStrategyFix").
+		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+
+	if err := migrateFreedomDomainStrategy(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	got := storedTemplate(t)
+	var cfg struct {
+		Outbounds []map[string]any `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(got), &cfg); err != nil {
+		t.Fatalf("stored template is not JSON: %v", err)
+	}
+	if len(cfg.Outbounds) != 1 {
+		t.Fatalf("stored outbounds = %d, want 1", len(cfg.Outbounds))
+	}
+	outbound := cfg.Outbounds[0]
+	if _, present := outbound["targetStrategy"]; present {
+		t.Errorf("stored outbound kept the root targetStrategy: %s", got)
+	}
+	settings, _ := outbound["settings"].(map[string]any)
+	if _, present := settings["domainStrategy"]; present {
+		t.Errorf("stored outbound kept the deprecated settings key: %s", got)
+	}
+	stream, _ := outbound["streamSettings"].(map[string]any)
+	sockopt, _ := stream["sockopt"].(map[string]any)
+	if sockopt["domainStrategy"] != "UseIPv4" {
+		t.Errorf("stored sockopt strategy = %v, want UseIPv4", sockopt["domainStrategy"])
+	}
+
+	// The history gate is what keeps a hand-edited template from being rewritten
+	// again on every restart, so run the real seeder list over a fresh legacy one.
+	seedTemplate(t, legacy)
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+	if got := storedTemplate(t); got != legacy {
+		t.Errorf("a completed seeder rewrote the template again: %s", got)
+	}
+}
+
+func seedTemplate(t *testing.T, value string) {
+	t.Helper()
+	if err := db.Where("key = ?", "xrayTemplateConfig").Delete(&model.Setting{}).Error; err != nil {
+		t.Fatalf("clear template: %v", err)
+	}
+	if err := db.Create(&model.Setting{Key: "xrayTemplateConfig", Value: value}).Error; err != nil {
+		t.Fatalf("seed template: %v", err)
+	}
+}
+
+func storedTemplate(t *testing.T) string {
+	t.Helper()
+	var setting model.Setting
+	if err := db.Where("key = ?", "xrayTemplateConfig").First(&setting).Error; err != nil {
+		t.Fatalf("reload template: %v", err)
+	}
+	return setting.Value
+}

+ 21 - 0
internal/sub/calendar_expire.go

@@ -0,0 +1,21 @@
+package sub
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func (s *SubService) subscriptionUserinfo(traffic xray.ClientTraffic) string {
+	expire := traffic.ExpiryTime / 1000
+	if s.subCalendarExpireInclusive && traffic.ResetDay == 1 && traffic.ExpiryTime > 0 && s.calendarExpireLocation != nil {
+		at := time.UnixMilli(traffic.ExpiryTime).In(s.calendarExpireLocation)
+		midnight := at.Day() == 1 && at.Hour() == 0 && at.Minute() == 0 && at.Second() == 0 && at.Nanosecond() == 0
+		if midnight && at.Add(-time.Second).Month() != at.Month() {
+			// Opt-in last-valid-second presentation; never change the real cutoff (#6516).
+			expire--
+		}
+	}
+	return fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, expire)
+}

+ 241 - 0
internal/sub/calendar_expire_test.go

@@ -0,0 +1,241 @@
+package sub
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSubscriptionCalendarExpireInclusive(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	tests := []struct {
+		name       string
+		setting    string
+		zone       string
+		boundary   string
+		resetDay   int
+		trafficDay int
+		wantSnap   bool
+	}{
+		{"default unchanged", "", "UTC", "2030-10-01T00:00:00Z", 1, 1, false},
+		{"disabled unchanged", "false", "UTC", "2030-10-01T00:00:00Z", 1, 1, false},
+		{"30 day month", "true", "UTC", "2030-10-01T00:00:00Z", 1, 1, true},
+		{"31 day month", "true", "UTC", "2030-11-01T00:00:00Z", 1, 1, true},
+		{"non leap February", "true", "UTC", "2030-03-01T00:00:00Z", 1, 1, true},
+		{"leap February", "true", "UTC", "2028-03-01T00:00:00Z", 1, 1, true},
+		{"Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00+08:00", 1, 1, true},
+		{"New York daylight time", "true", "America/New_York", "2030-10-01T00:00:00-04:00", 1, 1, true},
+		{"New York standard time", "true", "America/New_York", "2030-02-01T00:00:00-05:00", 1, 1, true},
+		{"Havana first midnight", "true", "America/Havana", "2026-11-01T00:00:00-04:00", 1, 1, true},
+		{"Havana repeated midnight", "true", "America/Havana", "2026-11-01T00:00:00-05:00", 1, 1, false},
+		{"UTC midnight is not Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00Z", 1, 1, false},
+		{"midday unchanged", "true", "UTC", "2030-10-01T12:00:00Z", 1, 1, false},
+		{"other midnight unchanged", "true", "UTC", "2030-10-02T00:00:00Z", 1, 1, false},
+		{"fractional midnight unchanged", "true", "UTC", "2030-10-01T00:00:00.001Z", 1, 1, false},
+		{"legacy inclusive input unchanged", "true", "UTC", "2030-09-30T23:59:59Z", 1, 1, false},
+		{"interval renewal unchanged", "true", "UTC", "2030-10-01T00:00:00Z", 0, 0, false},
+		{"other billing day unchanged", "true", "UTC", "2030-10-01T00:00:00Z", 31, 31, false},
+		{"client calendar overrides stale traffic", "true", "UTC", "2030-10-01T00:00:00Z", 1, 0, true},
+		{"client interval overrides stale traffic", "true", "UTC", "2030-10-01T00:00:00Z", 0, 1, false},
+		{"unlimited unchanged", "true", "UTC", "", 1, 1, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			seedSubDB(t)
+			db := database.GetDB()
+			if err := db.Create(&model.Setting{Key: "timeLocation", Value: tt.zone}).Error; err != nil {
+				t.Fatal(err)
+			}
+			if tt.setting != "" {
+				if err := db.Create(&model.Setting{Key: "subCalendarExpireInclusive", Value: tt.setting}).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			var expiry int64
+			if tt.boundary != "" {
+				at, err := time.Parse(time.RFC3339Nano, tt.boundary)
+				if err != nil {
+					t.Fatal(err)
+				}
+				expiry = at.UnixMilli()
+			}
+			seedSubProtocolInbound(t, "calendar", "monthly", 4931, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
+			if err := db.Model(&model.ClientRecord{}).Where("email = ?", "monthly@e").Updates(map[string]any{
+				"expiry_time": expiry, "reset_day": tt.resetDay,
+			}).Error; err != nil {
+				t.Fatal(err)
+			}
+			// Node snapshots may omit limits; the clients table still owns the calendar.
+			if err := db.Create(&xray.ClientTraffic{
+				Email: "monthly@e", Enable: true, Up: 11, Down: 22, ResetDay: tt.trafficDay, ResetCount: 7,
+			}).Error; err != nil {
+				t.Fatal(err)
+			}
+			router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+			wantExpiry := expiry / 1000
+			if tt.wantSnap {
+				wantExpiry--
+			}
+			wantHeader := fmt.Sprintf("upload=11; download=22; total=0; expire=%d", wantExpiry)
+			for _, path := range []string{"/sub/calendar", "/json/calendar", "/clash/calendar", "/mihomo/calendar", "/clash-legacy/calendar"} {
+				resp := httptest.NewRecorder()
+				router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil))
+				if resp.Code != http.StatusOK {
+					t.Fatalf("GET %s: status=%d body=%s", path, resp.Code, resp.Body.String())
+				}
+				if got := resp.Header().Get("Subscription-Userinfo"); got != wantHeader {
+					t.Fatalf("GET %s: userinfo=%q, want %q", path, got, wantHeader)
+				}
+			}
+
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/calendar?format=info", nil))
+			var info struct {
+				Expire int64 `json:"expire"`
+			}
+			if resp.Code != http.StatusOK {
+				t.Fatalf("info status=%d body=%s", resp.Code, resp.Body.String())
+			}
+			if err := json.Unmarshal(resp.Body.Bytes(), &info); err != nil {
+				t.Fatal(err)
+			}
+			if info.Expire != expiry/1000 {
+				t.Fatalf("info cutoff=%d, want canonical %d", info.Expire, expiry/1000)
+			}
+			var client model.ClientRecord
+			var traffic xray.ClientTraffic
+			if err := db.Where("email = ?", "monthly@e").First(&client).Error; err != nil {
+				t.Fatal(err)
+			}
+			if err := db.Where("email = ?", "monthly@e").First(&traffic).Error; err != nil {
+				t.Fatal(err)
+			}
+			if client.ExpiryTime != expiry || traffic.ResetCount != 7 || traffic.Up != 11 || traffic.Down != 22 {
+				t.Fatalf("subscription presentation mutated scheduling/accounting: client=%+v traffic=%+v", client, traffic)
+			}
+		})
+	}
+}
+
+func TestSubscriptionCalendarExpireInclusiveMixedClients(t *testing.T) {
+	tests := []struct {
+		name       string
+		days       [2]int
+		different  bool
+		wantExpiry int64
+	}{
+		{"calendar then interval", [2]int{1, 0}, false, 1917043200},
+		{"interval then calendar", [2]int{0, 1}, false, 1917043200},
+		{"same calendar", [2]int{1, 1}, false, 1917043199},
+		{"different cutoffs", [2]int{1, 1}, true, 0},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			seedSubDB(t)
+			db := database.GetDB()
+			for key, value := range map[string]string{"timeLocation": "UTC", "subCalendarExpireInclusive": "true"} {
+				if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			const expiry = int64(1917043200000) // 2030-10-01 00:00:00 UTC
+			for i, day := range tt.days {
+				tag := fmt.Sprintf("client%d", i)
+				seedSubInbound(t, "mixed", tag, 4932+i, i, `{"network":"tcp","security":"none"}`)
+				clientExpiry := expiry
+				if i == 1 && tt.different {
+					clientExpiry += 31 * 24 * time.Hour.Milliseconds()
+				}
+				if err := db.Model(&model.ClientRecord{}).Where("email = ?", tag+"@e").Updates(map[string]any{
+					"expiry_time": clientExpiry, "reset_day": day,
+				}).Error; err != nil {
+					t.Fatal(err)
+				}
+				if err := db.Create(&xray.ClientTraffic{Email: tag + "@e", Enable: true}).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+			for _, path := range []string{"/sub/mixed", "/json/mixed", "/clash/mixed"} {
+				resp := httptest.NewRecorder()
+				router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil))
+				if resp.Code != http.StatusOK || !strings.HasSuffix(resp.Header().Get("Subscription-Userinfo"), fmt.Sprintf("expire=%d", tt.wantExpiry)) {
+					t.Fatalf("GET %s: status=%d userinfo=%q, want expire=%d", path, resp.Code, resp.Header().Get("Subscription-Userinfo"), tt.wantExpiry)
+				}
+			}
+		})
+	}
+}
+
+func TestSubscriptionCalendarExpireInclusiveSettingRoundTrip(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	seedSubInbound(t, "toggle", "monthly", 4935, 1, `{"network":"tcp","security":"none"}`)
+	const expiry = int64(1917043200000)
+	if err := db.Model(&model.ClientRecord{}).Where("email = ?", "monthly@e").Updates(map[string]any{
+		"expiry_time": expiry, "reset_day": 1,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: "monthly@e", Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+	settings := &service.SettingService{}
+	all, err := settings.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if all.SubCalendarExpireInclusive {
+		t.Fatal("inclusive presentation must default off")
+	}
+	all.TimeLocation = "UTC"
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+	for _, enabled := range []bool{false, true, false} {
+		all.SubCalendarExpireInclusive = enabled
+		if err := settings.UpdateAllSetting(all, service.SecretClears{}); err != nil {
+			t.Fatal(err)
+		}
+		stored, err := settings.GetAllSetting()
+		if err != nil || stored.SubCalendarExpireInclusive != enabled {
+			t.Fatalf("setting round trip: enabled=%v stored=%+v err=%v", enabled, stored, err)
+		}
+		resp := httptest.NewRecorder()
+		router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/toggle", nil))
+		want := expiry / 1000
+		if enabled {
+			want--
+		}
+		if got := resp.Header().Get("Subscription-Userinfo"); resp.Code != http.StatusOK || got != fmt.Sprintf("upload=0; download=0; total=0; expire=%d", want) {
+			t.Fatalf("enabled=%v: status=%d userinfo=%q, want expire=%d", enabled, resp.Code, got, want)
+		}
+	}
+}
+
+func TestSubscriptionCalendarExpireInclusiveFirstUseDuration(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	const duration = -int64(24 * time.Hour / time.Millisecond)
+	if err := db.Create(&model.ClientRecord{Email: "first-use@e", ExpiryTime: duration, ResetDay: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: "first-use@e", ExpiryTime: duration, ResetDay: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	before := time.Now().UnixMilli()
+	agg, _ := (&SubService{}).AggregateTrafficByEmails([]string{"first-use@e"})
+	after := time.Now().UnixMilli()
+	if agg.ResetDay != 0 || agg.ExpiryTime < before-duration || agg.ExpiryTime > after-duration {
+		t.Fatalf("first-use duration must not become a canonical calendar cutoff: %+v", agg)
+	}
+}

+ 1 - 1
internal/sub/clash_service.go

@@ -113,7 +113,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
 	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 	traffic.Enable = hasEnabledClient
-	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	header := subReq.subscriptionUserinfo(traffic)
 
 	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone {
 		dummyProxy := map[string]any{

+ 1 - 1
internal/sub/controller.go

@@ -484,7 +484,7 @@ func (a *SUBController) subs(c *gin.Context) {
 		}
 
 		// Add headers
-		header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+		header := subReq.subscriptionUserinfo(traffic)
 		profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 		metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
 		a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)

+ 1 - 1
internal/sub/json_service.go

@@ -220,7 +220,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 	traffic.Enable = hasEnabledClient
-	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	header = subReq.subscriptionUserinfo(traffic)
 
 	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(configArray) > 0); mode != infoNodeNone {
 		dummyConfig := s.genDummySocksConfig(remark)

+ 20 - 5
internal/sub/service.go

@@ -48,6 +48,8 @@ type SubService struct {
 	usageShown                 map[string]bool
 	showIdentityOnAllLinks     bool
 	subInfoNodeEnable          bool
+	subCalendarExpireInclusive bool
+	calendarExpireLocation     *time.Location
 	subExpiredTemplate         string
 	subTrafficDepletedTemplate string
 	inboundService             service.InboundService
@@ -111,6 +113,11 @@ func (s *SubService) PrepareForRequest(host string) {
 	s.settingsByInbound = map[int]map[string]any{}
 	s.loadNodes()
 	s.loadRemarkSettings()
+	s.subCalendarExpireInclusive, _ = s.settingService.GetSubCalendarExpireInclusive()
+	s.calendarExpireLocation = nil
+	if s.subCalendarExpireInclusive {
+		s.calendarExpireLocation, _ = s.settingService.GetTimeLocation()
+	}
 }
 
 // primeLinkClients caches clients (first occurrence per email, matching the
@@ -544,13 +551,13 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
 	// runtime traffic rows. In a multi-node setup the node snapshot can reset
 	// client_traffics.total/expiry_time to 0, so fall back to the clients
 	// table to keep the Subscription-Userinfo header in sync with the UI (#4645).
-	limits := make(map[string][2]int64, len(emails))
+	limits := make(map[string]model.ClientRecord, len(emails))
 	var records []model.ClientRecord
 	if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Find(&records).Error; err != nil {
 		logger.Warning("SubService - AggregateTrafficByEmails: load client limits:", err)
 	} else {
 		for _, r := range records {
-			limits[r.Email] = [2]int64{r.TotalGB, r.ExpiryTime}
+			limits[r.Email] = r
 		}
 	}
 
@@ -560,25 +567,33 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
 		if ct.LastOnline > lastOnline {
 			lastOnline = ct.LastOnline
 		}
-		total, expiry := ct.Total, ct.ExpiryTime
+		total, expiry, resetDay := ct.Total, ct.ExpiryTime, ct.ResetDay
 		if lim, ok := limits[ct.Email]; ok {
+			resetDay = lim.ResetDay
 			if total == 0 {
-				total = lim[0]
+				total = lim.TotalGB
 			}
 			if expiry == 0 {
-				expiry = lim[1]
+				expiry = lim.ExpiryTime
 			}
 		}
+		if expiry <= 0 {
+			resetDay = 0
+		}
 		if first {
 			agg.Up = ct.Up
 			agg.Down = ct.Down
 			agg.Total = total
 			agg.ExpiryTime = subscriptionExpiryFromClient(now, expiry)
+			agg.ResetDay = resetDay
 			first = false
 			continue
 		}
 		agg.Up += ct.Up
 		agg.Down += ct.Down
+		if resetDay != agg.ResetDay {
+			agg.ResetDay = 0
+		}
 		if agg.Total == 0 || total == 0 {
 			agg.Total = 0
 		} else {

+ 1 - 0
internal/web/entity/entity.go

@@ -37,6 +37,7 @@ type AllSetting struct {
 	RemarkTemplate             string `json:"remarkTemplate" form:"remarkTemplate"`
 	SubShowIdentityOnAllLinks  bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
 	SubInfoNodeEnable          bool   `json:"subInfoNodeEnable" form:"subInfoNodeEnable"`
+	SubCalendarExpireInclusive bool   `json:"subCalendarExpireInclusive" form:"subCalendarExpireInclusive"`
 	SubExpiredTemplate         string `json:"subExpiredTemplate" form:"subExpiredTemplate"`
 	SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"`
 	Datepicker                 string `json:"datepicker" form:"datepicker"`

+ 0 - 1
internal/web/service/config.json

@@ -31,7 +31,6 @@
   "outbounds": [{
       "protocol": "freedom",
       "settings": {
-        "domainStrategy": "AsIs",
         "finalRules": [
           { "action": "block", "ip": ["geoip:private"] },
           { "action": "allow" }

+ 37 - 0
internal/web/service/default_template_freedom_test.go

@@ -0,0 +1,37 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+// The embedded template is what every fresh install starts from, so it must not
+// carry the freedom strategy keys the core warns about on every config load.
+func TestDefaultXrayTemplateKeepsFreedomStrategyOutOfTheLegacyKeys(t *testing.T) {
+	var cfg struct {
+		Outbounds []map[string]any `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(xrayTemplateConfig), &cfg); err != nil {
+		t.Fatalf("embedded config.json is not JSON: %v", err)
+	}
+	freedom := 0
+	for _, ob := range cfg.Outbounds {
+		if ob["protocol"] != "freedom" {
+			continue
+		}
+		freedom++
+		tag := ob["tag"]
+		if _, ok := ob["targetStrategy"]; ok {
+			t.Errorf("freedom outbound %v carries the outbound-root targetStrategy", tag)
+		}
+		settings, _ := ob["settings"].(map[string]any)
+		for _, key := range []string{"domainStrategy", "targetStrategy"} {
+			if _, ok := settings[key]; ok {
+				t.Errorf("freedom outbound %v carries the deprecated settings.%s", tag, key)
+			}
+		}
+	}
+	if freedom == 0 {
+		t.Fatal("the default template has no freedom outbound to check")
+	}
+}

+ 5 - 0
internal/web/service/setting.go

@@ -75,6 +75,7 @@ var defaultValueMap = map[string]string{
 	"remarkTemplate":              DefaultRemarkTemplate,
 	"subShowIdentityOnAllLinks":   "false",
 	"subInfoNodeEnable":           "false",
+	"subCalendarExpireInclusive":  "false",
 	"subExpiredTemplate":          DefaultSubExpiredTemplate,
 	"subTrafficDepletedTemplate":  DefaultSubTrafficDepletedTemplate,
 	"timeLocation":                "Local",
@@ -722,6 +723,10 @@ func (s *SettingService) GetSubInfoNodeEnable() (bool, error) {
 	return s.getBool("subInfoNodeEnable")
 }
 
+func (s *SettingService) GetSubCalendarExpireInclusive() (bool, error) {
+	return s.getBool("subCalendarExpireInclusive")
+}
+
 func (s *SettingService) GetSubExpiredTemplate() (string, error) {
 	return s.getString("subExpiredTemplate")
 }

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

@@ -1493,6 +1493,8 @@
           "note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "تكوين معلومات منفصل / عقدة وهمية",
       "subInfoNodeEnableDesc": "عند التفعيل، يتم عرض الملاحظات وحركة المرور/الأيام المتبقية كتكوين SOCKS منفصل في الأعلى، وعند انتهاء الصلاحية أو نفاد البيانات يتم إرجاع تكوين الحالة فقط.",
       "subExpiredTemplate": "قالب انتهاء الصلاحية",

+ 2 - 0
internal/web/translation/en-US.json

@@ -1611,6 +1611,8 @@
           "note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Separate Info Config / Dummy Node",
       "subInfoNodeEnableDesc": "When enabled, remarks and remaining traffic/days are served as a separate dummy SOCKS config at the top, and expired/depleted subscriptions return only the status config.",
       "subExpiredTemplate": "Expired Template",

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

@@ -1493,6 +1493,8 @@
           "note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Configuración de información separada / Nodo ficticio",
       "subInfoNodeEnableDesc": "Si está activado, la información de tráfico y días restantes se muestra en un nodo SOCKS ficticio arriba, y las suscripciones caducadas o agotadas devuelven solo el estado.",
       "subExpiredTemplate": "Plantilla de expirado",

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

@@ -1493,6 +1493,8 @@
           "note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "کانفیگ جداگانه اطلاعات / نود نمایشی",
       "subInfoNodeEnableDesc": "در صورت فعال بودن، مشخصات و حجم/روزهای باقیمانده به عنوان یک کانفیگ مجزای ساکس در بالای لیست نمایش داده می‌شود و در صورت انقضا یا اتمام حجم فقط پیام وضعیت ارسال می‌شود.",
       "subExpiredTemplate": "قالب پیام انقضا",

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

@@ -1493,6 +1493,8 @@
           "note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Konfigurasi Info Terpisah / Node Dummy",
       "subInfoNodeEnableDesc": "Saat diaktifkan, catatan dan sisa kuota/hari disajikan sebagai konfigurasi SOCKS dummy terpisah di bagian atas, dan langganan kedaluwarsa/habis hanya mengembalikan status.",
       "subExpiredTemplate": "Templat Kedaluwarsa",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "個別情報設定 / ダミーノード",
       "subInfoNodeEnableDesc": "有効にすると、備考と残りの通信量/日数が上部の独立したSOCKSダミー構成として表示され、期限切れや通信量超過時はステータスのみが返されます。",
       "subExpiredTemplate": "期限切れテンプレート",

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

@@ -1493,6 +1493,8 @@
           "note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Configuração de informações separada / Nó fictício",
       "subInfoNodeEnableDesc": "Quando ativado, observações e tráfego/dias restantes são exibidos em uma configuração SOCKS fictícia no topo, e assinaturas expiradas/esgotadas retornam apenas o status.",
       "subExpiredTemplate": "Modelo expirado",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1493,6 +1493,8 @@
           "note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Отдельный инфо-конфиг / фиктивный узел",
       "subInfoNodeEnableDesc": "Если включено, примечание и остаток трафика/дней отображаются отдельным SOCKS-конфигом вверху, а при истечении срока/трафика возвращается только статус.",
       "subExpiredTemplate": "Шаблон истекшей подписки",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Ayrı Bilgi Yapılandırması / Sahte Düğüm",
       "subInfoNodeEnableDesc": "Etkinleştirildiğinde, notlar ve kalan trafik/günler en üstte ayrı bir sahte SOCKS yapılandırması olarak sunulur; süresi dolmuş veya kotası bitmiş abonelikler yalnızca durum yapılandırmasını alır.",
       "subExpiredTemplate": "Süresi Dolmuş Şablonu",

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

@@ -1493,6 +1493,8 @@
           "note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Окремий інфо-конфіг / фіктивний вузол",
       "subInfoNodeEnableDesc": "Якщо увімкнено, примітка та залишок трафіку/днів відображаються окремим SOCKS-конфігом угорі, а після закінчення терміну/трафіку повертається лише статус.",
       "subExpiredTemplate": "Шаблон закінчення терміну",

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

@@ -1493,6 +1493,8 @@
           "note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Cấu hình thông tin riêng / Nút ảo",
       "subInfoNodeEnableDesc": "Khi bật, ghi chú và lưu lượng/ngày còn lại sẽ hiển thị dưới dạng cấu hình SOCKS ảo riêng ở trên cùng, và đăng ký hết hạn/hết dung lượng chỉ trả về cấu hình trạng thái.",
       "subExpiredTemplate": "Mẫu hết hạn",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
         }
       },
+      "subCalendarExpireInclusive": "订阅到期日期显示为月底",
+      "subCalendarExpireInclusiveDesc": "默认关闭。对于按每月 1 日续期、且到期时间为面板时区午夜的客户端,订阅到期时间报告为上月最后一秒。真实截止时间和续期不变。客户端使用自身时区,可能提前一秒显示过期。",
       "subInfoNodeEnable": "独立信息配置 / 提示节点",
       "subInfoNodeEnableDesc": "启用后,备注及剩余流量/天数将作为单独的 SOCKS 提示配置显示在顶部;过期或流量耗尽时仅返回状态提示。",
       "subExpiredTemplate": "过期提示模板",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
         }
       },
+      "subCalendarExpireInclusive": "訂閱到期日期顯示為月底",
+      "subCalendarExpireInclusiveDesc": "預設關閉。對於按每月 1 日續期、且到期時間為面板時區午夜的用戶端,訂閱到期時間回報為上月最後一秒。真實截止時間和續期不變。用戶端使用自身時區,可能提前一秒顯示過期。",
       "subInfoNodeEnable": "獨立資訊配置 / 提示節點",
       "subInfoNodeEnableDesc": "啟用後,備註及剩餘流量/天數將作為單獨的 SOCKS 提示配置顯示在頂部;過期或流量耗盡時僅返回狀態提示。",
       "subExpiredTemplate": "過期提示範本",