23 Commity 588ea86298 ... 950a647bcc

Autor SHA1 Wiadomość Data
  MHSanaei 950a647bcc v3.2.6 17 godzin temu
  MHSanaei c8ad42631c fix(migrate): copy composite-key tables without FindInBatches (#4787) 17 godzin temu
  MHSanaei 4f597a08c4 perf(clients): batch bulk attach/detach to cut per-item DB work 18 godzin temu
  MHSanaei d56505004e style: gofmt -s (doc-comment list separator, struct field alignment) 18 godzin temu
  MHSanaei f0e459e51e fix(node): suppress unavoidable InsecureSkipVerify alert for cert pinning 18 godzin temu
  MHSanaei 327228d8f3 Remove .svg extension from shields URLs in READMEs 18 godzin temu
  MHSanaei d2dc589f14 fix(node): capture node cert via VerifyConnection for fingerprint fetch 18 godzin temu
  MHSanaei 87f446fe22 docs(readme): revamp README and sync all translations 18 godzin temu
  MHSanaei 49ef1449f1 fix(clients): keep Add Client modal in viewport with internal scroll 18 godzin temu
  MHSanaei b9612f1326 fix(xray): clear dirty state after saving unchanged config 19 godzin temu
  MHSanaei 7bc31dd194 feat(outbounds): pick dialerProxy from other outbound tags for proxy chaining 20 godzin temu
  Mayurifag 8fa248c621 fix(job): skip fail2ban IP limit when disabled (#4581) 20 godzin temu
  MHSanaei 01d2ec5061 chore(generated): sync node types/zod with TLS verification fields (#4757) 20 godzin temu
  MHSanaei 56ec359041 feat(nodes): add per-node TLS verification mode for self-signed certs (#4757) 20 godzin temu
  MHSanaei b2e2120eb3 feat(inbounds): support Unix domain socket path in Listen field (#4429) 21 godzin temu
  MHSanaei cb17eb8c06 feat(x-ui.sh): support Cloudflare API Token for DNS SSL (menu 20) (#4595) 21 godzin temu
  MHSanaei 49bec1db0f fix(fallbacks): allow free-form dest entries for external servers (#4748) 21 godzin temu
  MHSanaei 5b6e05a0fc fix(raw): complete the HTTP header section for inbound and outbound 22 godzin temu
  MHSanaei bcb982aeba fix(x-ui.sh): preserve 2FA on credential reset (#4758) 22 godzin temu
  MHSanaei ccd0853b6c fix(inbounds): allow port 0 for UDS inbounds (#4783) 22 godzin temu
  MHSanaei 3657ed55dc fix(warp): persist client_id so WARP outbound gets reserved bytes (#4781) 22 godzin temu
  MHSanaei 47d9b49666 feat(x-ui.sh): add PostgreSQL management menu 22 godzin temu
  MHSanaei 5b9ed34009 fix(nodes): sum client traffic across nodes instead of overwriting 23 godzin temu
92 zmienionych plików z 2788 dodań i 246 usunięć
  1. 126 12
      README.ar_EG.md
  2. 127 13
      README.es_ES.md
  3. 126 12
      README.fa_IR.md
  4. 89 13
      README.md
  5. 126 12
      README.ru_RU.md
  6. 126 12
      README.zh_CN.md
  7. 1 1
      config/version
  8. 1 0
      database/db.go
  9. 1 1
      database/db_seed_test.go
  10. 31 12
      database/migrate_data.go
  11. 64 0
      database/migrate_data_test.go
  12. 4 1
      database/model/model.go
  13. 9 0
      database/model/node_client_traffic.go
  14. 50 0
      frontend/public/openapi.json
  15. 2 0
      frontend/src/api/queries/useNodeMutations.ts
  16. 2 0
      frontend/src/generated/types.ts
  17. 3 1
      frontend/src/generated/zod.ts
  18. 15 7
      frontend/src/hooks/useXraySetting.ts
  19. 9 2
      frontend/src/lib/xray/inbound-link.ts
  20. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  21. 2 0
      frontend/src/pages/clients/ClientFormModal.tsx
  22. 2 1
      frontend/src/pages/inbounds/form/FallbacksCard.tsx
  23. 8 2
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  24. 0 7
      frontend/src/pages/inbounds/form/transport/raw.tsx
  25. 2 2
      frontend/src/pages/inbounds/form/useInboundFallbacks.ts
  26. 67 0
      frontend/src/pages/nodes/NodeFormModal.tsx
  27. 2 1
      frontend/src/pages/nodes/NodesPage.tsx
  28. 3 1
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  29. 18 3
      frontend/src/pages/xray/outbounds/transport/raw.tsx
  30. 22 2
      frontend/src/pages/xray/outbounds/transport/sockopt.tsx
  31. 2 1
      frontend/src/pages/xray/overrides/WarpModal.tsx
  32. 2 2
      frontend/src/schemas/api/inbound.ts
  33. 2 2
      frontend/src/schemas/forms/inbound-form.ts
  34. 4 0
      frontend/src/schemas/node.ts
  35. 3 0
      frontend/src/schemas/primitives/port.ts
  36. 13 0
      frontend/src/test/inbound-link.test.ts
  37. BIN
      media/01-overview-dark.png
  38. BIN
      media/01-overview-light.png
  39. BIN
      media/02-add-inbound-dark.png
  40. BIN
      media/02-add-inbound-light.png
  41. BIN
      media/02-inbounds-dark.png
  42. BIN
      media/02-inbounds-light.png
  43. BIN
      media/03-add-client-dark.png
  44. BIN
      media/03-add-client-light.png
  45. BIN
      media/03-add-inbound-dark.png
  46. BIN
      media/03-add-inbound-light.png
  47. BIN
      media/03-client-dark.png
  48. BIN
      media/03-client-light.png
  49. BIN
      media/04-add-client-dark.png
  50. BIN
      media/04-add-client-light.png
  51. BIN
      media/04-group-dark.png
  52. BIN
      media/04-group-light.png
  53. BIN
      media/05-add-nodes-dark.png
  54. BIN
      media/05-add-nodes-light.png
  55. BIN
      media/05-nodes-dark.png
  56. BIN
      media/05-nodes-light.png
  57. BIN
      media/05-settings-dark.png
  58. BIN
      media/05-settings-light.png
  59. BIN
      media/06-configs-dark.png
  60. BIN
      media/06-configs-light.png
  61. BIN
      media/06-settings-dark.png
  62. BIN
      media/06-settings-light.png
  63. BIN
      media/07-configs-dark.png
  64. BIN
      media/07-configs-light.png
  65. BIN
      media/08-api-docs-dark.png
  66. BIN
      media/08-api-docs-light.png
  67. 24 0
      web/controller/node.go
  68. 16 5
      web/job/check_client_ip_job.go
  69. 43 0
      web/job/check_client_ip_job_integration_test.go
  70. 75 0
      web/job/check_client_ip_job_test.go
  71. 244 0
      web/service/bulk_clients_test.go
  72. 199 18
      web/service/client.go
  73. 11 7
      web/service/fallback.go
  74. 83 26
      web/service/inbound.go
  75. 133 1
      web/service/node.go
  76. 209 0
      web/service/node_client_traffic_sum_test.go
  77. 5 5
      web/service/sub_uri_base_test.go
  78. 5 0
      web/service/warp.go
  79. 18 3
      web/translation/ar-EG.json
  80. 18 3
      web/translation/en-US.json
  81. 18 3
      web/translation/es-ES.json
  82. 18 3
      web/translation/fa-IR.json
  83. 18 3
      web/translation/id-ID.json
  84. 18 3
      web/translation/ja-JP.json
  85. 18 3
      web/translation/pt-BR.json
  86. 18 3
      web/translation/ru-RU.json
  87. 18 3
      web/translation/tr-TR.json
  88. 18 3
      web/translation/uk-UA.json
  89. 18 3
      web/translation/vi-VN.json
  90. 18 3
      web/translation/zh-CN.json
  91. 18 3
      web/translation/zh-TW.json
  92. 436 22
      x-ui.sh

+ 126 - 12
README.ar_EG.md

@@ -7,29 +7,143 @@
   </picture>
 </p>
 
-[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
-[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
-[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
-[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
-[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
-[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
-[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
+<p align="center">
+  <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
+  <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
+  <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
+  <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
+  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
+</p>
 
-**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
+**3X-UI** هي لوحة تحكم ويب متقدمة ومفتوحة المصدر لإدارة خوادم [Xray-core](https://github.com/XTLS/Xray-core). توفّر واجهة نظيفة ومتعددة اللغات لنشر وتكوين ومراقبة مجموعة واسعة من بروتوكولات الوكيل وVPN — من خادم VPS واحد إلى عمليات النشر متعددة العقد.
 
-> [!IMPORTANT]
-> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج.
+تم بناء 3X-UI كنسخة محسّنة (fork) من مشروع X-UI الأصلي، وتضيف دعمًا أوسع للبروتوكولات، واستقرارًا محسّنًا، ومحاسبة للترافيك لكل عميل، والعديد من ميزات تحسين تجربة الاستخدام.
 
-كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
+> [!IMPORTANT]
+> هذا المشروع مخصص للاستخدام الشخصي فقط. يرجى عدم استخدامه لأغراض غير قانونية أو في بيئة إنتاجية.
+
+## الميزات
+
+- **اتصالات واردة متعددة البروتوكولات** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
+- **وسائل نقل وأمان حديثة** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، مؤمَّنة بـ TLS و XTLS و REALITY.
+- **Fallback** — تقديم عدة بروتوكولات على منفذ واحد (مثل VLESS و Trojan على المنفذ 443) باستخدام ميزة fallback في Xray.
+- **إدارة لكل عميل** — حصص الترافيك، تواريخ انتهاء الصلاحية، حدود IP، حالة الاتصال المباشرة، وروابط مشاركة وأكواد QR واشتراكات بنقرة واحدة.
+- **إحصائيات الترافيك** — لكل اتصال وارد، ولكل عميل، ولكل اتصال صادر، مع عناصر تحكم لإعادة التعيين.
+- **دعم العقد المتعددة** — إدارة وتوسيع عبر عدة خوادم من لوحة واحدة.
+- **الاتصالات الصادرة والتوجيه** — WARP، NordVPN، قواعد توجيه مخصصة، موازنات تحميل، وتسلسل الوكلاء الصادرة.
+- **خادم اشتراك مدمج** بصيغ إخراج متعددة.
+- **روبوت تيليجرام** للمراقبة والإدارة عن بُعد.
+- **واجهة RESTful API** مع توثيق Swagger داخل اللوحة.
+- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
+- **13 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
+- **تكامل مع Fail2ban** لفرض حدود IP لكل عميل.
+
+## لقطات الشاشة
+
+<details>
+<summary>انقر للتوسيع</summary>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
+  <img alt="Overview" src="./media/01-overview-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
+  <img alt="Inbounds" src="./media/02-add-inbound-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
+  <img alt="Add client" src="./media/03-add-client-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
+  <img alt="Configs" src="./media/05-add-nodes-light.png">
+</picture>
+
+</details>
 
 ## البدء السريع
 
-```
+```bash
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
+أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
+
 للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
 
+## المنصات المدعومة
+
+**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
+
+**المعماريات:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
+
+## خيارات قاعدة البيانات
+
+يدعم 3X-UI خلفيتين (backends) يتم اختيارهما أثناء التثبيت:
+
+- **SQLite** (افتراضي) — ملف واحد في `/etc/x-ui/x-ui.db`. بدون إعداد، مثالي لعمليات النشر الصغيرة والمتوسطة.
+- **PostgreSQL** — موصى به لأعداد العملاء الكبيرة أو الإعدادات متعددة العقد. يمكن للمثبِّت تثبيت PostgreSQL محليًا لك، أو قبول DSN لخادم موجود.
+
+في وقت التشغيل، يتم اختيار الخلفية عبر متغيرات البيئة (يكتبها المثبِّت لك في `/etc/default/x-ui`):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### ترحيل تثبيت SQLite موجود إلى PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# ثم عيّن XUI_DB_TYPE و XUI_DB_DSN في /etc/default/x-ui وأعد التشغيل:
+systemctl restart x-ui
+```
+
+يبقى ملف SQLite الأصلي دون تغيير؛ احذفه يدويًا بعد التحقق من الخلفية الجديدة.
+
+### Docker
+
+يستمر الأمر الافتراضي `docker compose up -d` في استخدام SQLite. للتشغيل مع خدمة PostgreSQL المرفقة، أزِل التعليق عن سطري متغيرات البيئة `XUI_DB_*` في `docker-compose.yml` وشغّل باستخدام البروفايل:
+
+```bash
+docker compose --profile postgres up -d
+```
+
+تتضمن الصورة Fail2ban (مُفعَّل افتراضيًا) لفرض **حدود IP** لكل عميل. يحظر Fail2ban المخالفين باستخدام `iptables`، الذي يتطلب صلاحية `NET_ADMIN`. يمنح `docker-compose.yml` هذه الصلاحية مسبقًا عبر `cap_add`؛ إذا شغّلت الحاوية باستخدام `docker run` بدلاً من ذلك، فأضِف الصلاحيات بنفسك، وإلا فسيتم تسجيل عمليات الحظر دون تطبيقها أبدًا:
+
+```bash
+docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
+```
+
+## متغيرات البيئة
+
+| المتغير | الوصف | الافتراضي |
+| --- | --- | --- |
+| `XUI_DB_TYPE` | خلفية قاعدة البيانات: `sqlite` أو `postgres` | `sqlite` |
+| `XUI_DB_DSN` | سلسلة اتصال PostgreSQL (عندما `XUI_DB_TYPE=postgres`) | — |
+| `XUI_DB_FOLDER` | مجلد ملف قاعدة بيانات SQLite | `/etc/x-ui` |
+| `XUI_DB_MAX_OPEN_CONNS` | الحد الأقصى للاتصالات المفتوحة (تجمّع PostgreSQL) | — |
+| `XUI_DB_MAX_IDLE_CONNS` | الحد الأقصى للاتصالات الخاملة (تجمّع PostgreSQL) | — |
+| `XUI_ENABLE_FAIL2BAN` | تفعيل فرض حدود IP المعتمد على Fail2ban | `true` |
+| `XUI_LOG_LEVEL` | مستوى السجل (`debug`، `info`، `warning`، `error`) | `info` |
+| `XUI_DEBUG` | تفعيل وضع التصحيح | `false` |
+
+## اللغات المدعومة
+
+تتوفر واجهة اللوحة بـ 13 لغة:
+
+English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+
+## المساهمة
+
+المساهمات مرحب بها. يرجى قراءة [دليل المساهمة](/CONTRIBUTING.md) قبل فتح مشكلة (issue) أو طلب سحب (pull request).
+
 ## شكر خاص إلى
 
 - [alireza0](https://github.com/alireza0/)

+ 127 - 13
README.es_ES.md

@@ -7,28 +7,142 @@
   </picture>
 </p>
 
-[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
-[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
-[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
-[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
-[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
-[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
-[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
+<p align="center">
+  <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
+  <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
+  <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
+  <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
+  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
+</p>
 
-**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
+**3X-UI** es un panel de control web avanzado y de código abierto para gestionar servidores [Xray-core](https://github.com/XTLS/Xray-core). Ofrece una interfaz limpia y multilingüe para desplegar, configurar y monitorear una amplia gama de protocolos de proxy y VPN — desde un único VPS hasta despliegues multinodo.
 
-> [!IMPORTANT]
-> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción.
+Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un soporte de protocolos más amplio, mayor estabilidad, contabilidad de tráfico por cliente y muchas funciones que mejoran la experiencia de uso.
 
-Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
+> [!IMPORTANT]
+> Este proyecto está destinado únicamente al uso personal. Por favor, no lo uses para fines ilegales ni en un entorno de producción.
+
+## Características
+
+- **Entradas multiprotocolo** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, 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.
+- **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, estado en línea en tiempo real y enlaces de compartición, códigos QR y suscripciones con un solo clic.
+- **Estadísticas de tráfico** — por entrada, por cliente y por salida, con controles de reinicio.
+- **Soporte multinodo** — gestiona y escala a través de varios servidores desde un único panel.
+- **Salida y enrutamiento** — WARP, NordVPN, reglas de enrutamiento personalizadas, balanceadores de carga y encadenamiento de proxy de salida.
+- **Servidor de suscripción integrado** con múltiples formatos de salida.
+- **Bot de Telegram** para monitorización y gestión remotas.
+- **API RESTful** con documentación Swagger dentro del panel.
+- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
+- **13 idiomas de interfaz** con temas oscuro y claro.
+- **Integración con Fail2ban** para aplicar límites de IP por cliente.
+
+## Capturas de pantalla
+
+<details>
+<summary>Haz clic para expandir</summary>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
+  <img alt="Overview" src="./media/01-overview-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
+  <img alt="Inbounds" src="./media/02-add-inbound-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
+  <img alt="Add client" src="./media/03-add-client-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
+  <img alt="Configs" src="./media/05-add-nodes-light.png">
+</picture>
+
+</details>
 
 ## Inicio Rápido
 
-```
+```bash
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
-Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
+Durante la instalación se generan un nombre de usuario, una contraseña y una ruta de acceso aleatorios. Tras la instalación, ejecuta `x-ui` para abrir el menú de gestión, donde puedes iniciar/detener el servicio, ver o restablecer tus credenciales de acceso, gestionar certificados SSL y mucho más.
+
+Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
+
+## Plataformas Compatibles
+
+**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine y Windows.
+
+**Arquitecturas:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
+
+## Opciones de Base de Datos
+
+3X-UI admite dos backends, que se eligen durante la instalación:
+
+- **SQLite** (predeterminado) — un único archivo en `/etc/x-ui/x-ui.db`. Sin configuración, ideal para despliegues pequeños y medianos.
+- **PostgreSQL** — recomendado para un gran número de clientes o configuraciones multinodo. El instalador puede instalar PostgreSQL localmente por ti, o aceptar un DSN a un servidor existente.
+
+En tiempo de ejecución, el backend se selecciona mediante variables de entorno (el instalador las escribe por ti en `/etc/default/x-ui`):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### Migrar una instalación de SQLite existente a PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# luego define XUI_DB_TYPE y XUI_DB_DSN en /etc/default/x-ui y reinicia:
+systemctl restart x-ui
+```
+
+El archivo SQLite de origen permanece intacto; elimínalo manualmente una vez que hayas verificado el nuevo backend.
+
+### Docker
+
+El comando predeterminado `docker compose up -d` sigue usando SQLite. Para ejecutarlo con el servicio PostgreSQL incluido, descomenta las dos líneas de variables de entorno `XUI_DB_*` en `docker-compose.yml` e inícialo con el perfil:
+
+```bash
+docker compose --profile postgres up -d
+```
+
+La imagen incluye Fail2ban (habilitado de forma predeterminada) para aplicar **límites de IP** por cliente. Fail2ban banea a los infractores con `iptables`, lo que requiere la capacidad `NET_ADMIN`. `docker-compose.yml` ya la concede mediante `cap_add`; si en su lugar inicias el contenedor con `docker run`, añade tú mismo las capacidades, de lo contrario los baneos se registran pero nunca se aplican:
+
+```bash
+docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
+```
+
+## Variables de Entorno
+
+| Variable | Descripción | Predeterminado |
+| --- | --- | --- |
+| `XUI_DB_TYPE` | Backend de base de datos: `sqlite` o `postgres` | `sqlite` |
+| `XUI_DB_DSN` | Cadena de conexión de PostgreSQL (cuando `XUI_DB_TYPE=postgres`) | — |
+| `XUI_DB_FOLDER` | Directorio del archivo de base de datos SQLite | `/etc/x-ui` |
+| `XUI_DB_MAX_OPEN_CONNS` | Máximo de conexiones abiertas (pool de PostgreSQL) | — |
+| `XUI_DB_MAX_IDLE_CONNS` | Máximo de conexiones inactivas (pool de PostgreSQL) | — |
+| `XUI_ENABLE_FAIL2BAN` | Habilitar la aplicación de límites de IP basada en Fail2ban | `true` |
+| `XUI_LOG_LEVEL` | Nivel de registro (`debug`, `info`, `warning`, `error`) | `info` |
+| `XUI_DEBUG` | Habilitar el modo de depuración | `false` |
+
+## Idiomas Compatibles
+
+La interfaz del panel está disponible en 13 idiomas:
+
+English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+
+## Contribuir
+
+Las contribuciones son bienvenidas. Por favor, lee la [Guía de contribución](/CONTRIBUTING.md) antes de abrir una incidencia (issue) o una solicitud de incorporación (pull request).
 
 ## Un Agradecimiento Especial a
 

+ 126 - 12
README.fa_IR.md

@@ -7,29 +7,143 @@
   </picture>
 </p>
 
-[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
-[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
-[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
-[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
-[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
-[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
-[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
+<p align="center">
+  <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
+  <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
+  <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
+  <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
+  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
+</p>
 
-**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکل‌های مختلف VPN و پراکسی ارائه می‌دهد.
+**3X-UI** یک پنل کنترل وب پیشرفته و متن‌باز برای مدیریت سرورهای [Xray-core](https://github.com/XTLS/Xray-core) است. این پنل یک رابط کاربری تمیز و چندزبانه برای استقرار، پیکربندی و نظارت بر طیف گسترده‌ای از پروتکل‌های پراکسی و VPN ارائه می‌دهد — از یک VPS تکی تا استقرارهای چندنودی.
 
-> [!IMPORTANT]
-> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید.
+‏3X-UI که به‌عنوان یک فورک بهبودیافته از پروژه‌ی اصلی X-UI ساخته شده است، پشتیبانی گسترده‌تر از پروتکل‌ها، پایداری بهتر، حسابداری ترافیک به‌ازای هر کلاینت و بسیاری از ویژگی‌های رفاهی را اضافه می‌کند.
 
-به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
+> [!IMPORTANT]
+> این پروژه فقط برای استفاده‌ی شخصی در نظر گرفته شده است. لطفاً از آن برای اهداف غیرقانونی یا در محیط تولید (production) استفاده نکنید.
+
+## ویژگی‌ها
+
+- **اینباندهای چندپروتکلی** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
+- **ترنسپورت‌ها و امنیت مدرن** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، ایمن‌شده با TLS، XTLS و REALITY.
+- **فال‌بک (Fallback)** — ارائه‌ی چند پروتکل روی یک پورت واحد (مثلاً VLESS و Trojan روی پورت 443) با استفاده از قابلیت fallback در Xray.
+- **مدیریت به‌ازای هر کلاینت** — سهمیه‌ی ترافیک، تاریخ انقضا، محدودیت IP، وضعیت آنلاینِ زنده و لینک‌های اشتراک‌گذاری، کدهای QR و سابسکریپشن‌ها با یک کلیک.
+- **آمار ترافیک** — به‌ازای هر اینباند، هر کلاینت و هر اوتباند، همراه با کنترل بازنشانی (reset).
+- **پشتیبانی از چند نود** — مدیریت و مقیاس‌دهی روی چندین سرور از یک پنل واحد.
+- **اوتباند و مسیریابی** — WARP، NordVPN، قوانین مسیریابی سفارشی، متعادل‌کننده‌های بار (load balancer) و زنجیره‌کردن پراکسی اوتباند.
+- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی.
+- **ربات تلگرام** برای نظارت و مدیریت از راه دور.
+- **‏RESTful API** همراه با مستندات Swagger درون‌پنل.
+- **ذخیره‌سازی منعطف** — SQLite (پیش‌فرض) یا PostgreSQL.
+- **‏۱۳ زبان رابط کاربری** با تم‌های تیره و روشن.
+- **یکپارچگی با Fail2ban** برای اعمال محدودیت IP به‌ازای هر کلاینت.
+
+## اسکرین‌شات‌ها
+
+<details>
+<summary>برای باز شدن کلیک کنید</summary>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
+  <img alt="Overview" src="./media/01-overview-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
+  <img alt="Inbounds" src="./media/02-add-inbound-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
+  <img alt="Add client" src="./media/03-add-client-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
+  <img alt="Configs" src="./media/05-add-nodes-light.png">
+</picture>
+
+</details>
 
 ## شروع سریع
 
-```
+```bash
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
+در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید می‌شود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا می‌توانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهی‌های SSL را مدیریت کنید و کارهای دیگری انجام دهید.
+
 برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
 
+## پلتفرم‌های پشتیبانی‌شده
+
+**سیستم‌عامل‌ها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
+
+**معماری‌ها:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
+
+## گزینه‌های پایگاه‌داده
+
+‏3X-UI از دو بک‌اند پشتیبانی می‌کند که در حین نصب انتخاب می‌شوند:
+
+- **SQLite** (پیش‌فرض) — یک فایل واحد در مسیر `/etc/x-ui/x-ui.db`. بدون نیاز به تنظیمات، ایده‌آل برای استقرارهای کوچک و متوسط.
+- **PostgreSQL** — برای تعداد کلاینت بالا یا راه‌اندازی‌های چندنودی توصیه می‌شود. نصب‌کننده می‌تواند PostgreSQL را به‌صورت محلی برایتان نصب کند، یا یک DSN به یک سرور موجود را بپذیرد.
+
+در زمان اجرا، بک‌اند از طریق متغیرهای محیطی انتخاب می‌شود (نصب‌کننده این موارد را برای شما در `/etc/default/x-ui` می‌نویسد):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### انتقال یک نصب موجود SQLite به PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# سپس XUI_DB_TYPE و XUI_DB_DSN را در /etc/default/x-ui تنظیم کرده و ری‌استارت کنید:
+systemctl restart x-ui
+```
+
+فایل اصلی SQLite دست‌نخورده باقی می‌ماند؛ پس از اطمینان از صحت بک‌اند جدید، آن را به‌صورت دستی حذف کنید.
+
+### Docker
+
+دستور پیش‌فرض `docker compose up -d` همچنان از SQLite استفاده می‌کند. برای اجرا با سرویس PostgreSQL همراه، دو خط متغیر محیطی `XUI_DB_*` را در `docker-compose.yml` از حالت کامنت خارج کنید و با پروفایل زیر اجرا کنید:
+
+```bash
+docker compose --profile postgres up -d
+```
+
+این ایمیج، Fail2ban را (که به‌صورت پیش‌فرض فعال است) برای اعمال **محدودیت‌های IP** به‌ازای هر کلاینت همراه دارد. ‏Fail2ban متخلفان را با `iptables` مسدود می‌کند که به مجوز `NET_ADMIN` نیاز دارد. فایل `docker-compose.yml` این مجوز را از قبل از طریق `cap_add` می‌دهد؛ اگر به‌جای آن کانتینر را با `docker run` اجرا می‌کنید، خودتان مجوزها را اضافه کنید، در غیر این صورت مسدودسازی‌ها فقط ثبت می‌شوند اما هرگز اعمال نمی‌شوند:
+
+```bash
+docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
+```
+
+## متغیرهای محیطی
+
+| متغیر | توضیحات | پیش‌فرض |
+| --- | --- | --- |
+| `XUI_DB_TYPE` | بک‌اند پایگاه‌داده: `sqlite` یا `postgres` | `sqlite` |
+| `XUI_DB_DSN` | رشته‌ی اتصال PostgreSQL (وقتی `XUI_DB_TYPE=postgres`) | — |
+| `XUI_DB_FOLDER` | پوشه‌ی فایل پایگاه‌داده‌ی SQLite | `/etc/x-ui` |
+| `XUI_DB_MAX_OPEN_CONNS` | حداکثر اتصالات باز (استخر PostgreSQL) | — |
+| `XUI_DB_MAX_IDLE_CONNS` | حداکثر اتصالات بی‌کار (استخر PostgreSQL) | — |
+| `XUI_ENABLE_FAIL2BAN` | فعال‌سازی اعمال محدودیت IP مبتنی بر Fail2ban | `true` |
+| `XUI_LOG_LEVEL` | سطح گزارش‌گیری (`debug`، `info`، `warning`، `error`) | `info` |
+| `XUI_DEBUG` | فعال‌سازی حالت دیباگ | `false` |
+
+## زبان‌های پشتیبانی‌شده
+
+رابط کاربری پنل به ۱۳ زبان در دسترس است:
+
+English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+
+## مشارکت
+
+از مشارکت‌ها استقبال می‌شود. لطفاً پیش از باز کردن issue یا pull request، [راهنمای مشارکت](/CONTRIBUTING.md) را مطالعه کنید.
+
 ## تشکر ویژه از
 
 - [alireza0](https://github.com/alireza0/)

+ 89 - 13
README.md

@@ -7,20 +7,65 @@
   </picture>
 </p>
 
-[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
-[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
-[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
-[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
-[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
-[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
-[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
+<p align="center">
+  <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
+  <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
+  <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
+  <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
+  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
+</p>
 
-**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
+**3X-UI** is an advanced, open-source web control panel for managing [Xray-core](https://github.com/XTLS/Xray-core) servers. It provides a clean, multi-language interface for deploying, configuring, and monitoring a wide range of proxy and VPN protocols — from a single VPS to multi-node deployments.
 
-> [!IMPORTANT]
-> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
+Built as an enhanced fork of the original X-UI project, 3X-UI adds broader protocol support, improved stability, per-client traffic accounting, and many quality-of-life features.
 
-As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
+> [!IMPORTANT]
+> This project is intended for personal use only. Please do not use it for illegal purposes or in a production environment.
+
+## Features
+
+- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, 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.
+- **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, live online status, and one-click share links, QR codes, and subscriptions.
+- **Traffic statistics** — per inbound, per client, and per outbound, with reset controls.
+- **Multi-node support** — manage and scale across multiple servers from a single panel.
+- **Outbound & routing** — WARP, NordVPN, custom routing rules, load balancers, and outbound proxy chaining.
+- **Built-in subscription server** with multiple output formats.
+- **Telegram bot** for remote monitoring and management.
+- **RESTful API** with in-panel Swagger documentation.
+- **Flexible storage** — SQLite (default) or PostgreSQL.
+- **13 UI languages** with dark and light themes.
+- **Fail2ban integration** for enforcing per-client IP limits.
+
+## Screenshots
+
+<details>
+<summary>Click to expand</summary>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
+  <img alt="Overview" src="./media/01-overview-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
+  <img alt="Inbounds" src="./media/02-add-inbound-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
+  <img alt="Add client" src="./media/03-add-client-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
+  <img alt="Configs" src="./media/05-add-nodes-light.png">
+</picture>
+
+</details>
 
 ## Quick Start
 
@@ -28,16 +73,24 @@ As an enhanced fork of the original X-UI project, 3X-UI provides improved stabil
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
+During installation a random username, password, and access path are generated. After installation, run `x-ui` to open the management menu, where you can start/stop the service, view or reset your login credentials, manage SSL certificates, and more.
+
 For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
 
+## Supported Platforms
+
+**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
+
+**Architectures:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
+
 ## Database Options
 
 3X-UI supports two backends, chosen during the install:
 
-- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
+- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small and medium deployments.
 - **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
 
-At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
+At runtime the backend is selected via environment variables (the installer writes these to `/etc/default/x-ui` for you):
 
 ```
 XUI_DB_TYPE=postgres
@@ -68,6 +121,29 @@ The image bundles Fail2ban (enabled by default) to enforce per-client **IP limit
 docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 ```
 
+## Environment Variables
+
+| Variable | Description | Default |
+| --- | --- | --- |
+| `XUI_DB_TYPE` | Database backend: `sqlite` or `postgres` | `sqlite` |
+| `XUI_DB_DSN` | PostgreSQL connection string (when `XUI_DB_TYPE=postgres`) | — |
+| `XUI_DB_FOLDER` | Directory for the SQLite database file | `/etc/x-ui` |
+| `XUI_DB_MAX_OPEN_CONNS` | Maximum open connections (PostgreSQL pool) | — |
+| `XUI_DB_MAX_IDLE_CONNS` | Maximum idle connections (PostgreSQL pool) | — |
+| `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
+| `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
+| `XUI_DEBUG` | Enable debug mode | `false` |
+
+## Supported Languages
+
+The panel UI is available in 13 languages:
+
+English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+
+## Contributing
+
+Contributions are welcome. Please read the [Contributing Guide](/CONTRIBUTING.md) before opening an issue or pull request.
+
 ## A Special Thanks to
 
 - [alireza0](https://github.com/alireza0/)

+ 126 - 12
README.ru_RU.md

@@ -7,29 +7,143 @@
   </picture>
 </p>
 
-[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
-[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
-[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
-[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
-[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
-[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
-[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
+<p align="center">
+  <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
+  <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
+  <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
+  <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
+  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
+</p>
 
-**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
+**3X-UI** — продвинутая веб-панель управления с открытым исходным кодом для управления серверами [Xray-core](https://github.com/XTLS/Xray-core). Она предоставляет аккуратный многоязычный интерфейс для развёртывания, настройки и мониторинга широкого спектра протоколов прокси и VPN — от одного VPS до развёртываний с несколькими узлами.
 
-> [!IMPORTANT]
-> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде.
+Созданный как улучшенный форк оригинального проекта X-UI, 3X-UI добавляет более широкую поддержку протоколов, повышенную стабильность, учёт трафика по каждому клиенту и множество функций для удобства использования.
 
-Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
+> [!IMPORTANT]
+> Этот проект предназначен только для личного использования. Пожалуйста, не используйте его в незаконных целях или в производственной среде.
+
+## Возможности
+
+- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
+- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
+- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
+- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
+- **Статистика трафика** — по каждому входящему, по каждому клиенту и по каждому исходящему, с возможностью сброса.
+- **Поддержка нескольких узлов** — управление и масштабирование на несколько серверов из одной панели.
+- **Исходящие подключения и маршрутизация** — WARP, NordVPN, пользовательские правила маршрутизации, балансировщики нагрузки и цепочки исходящих прокси.
+- **Встроенный сервер подписок** с несколькими форматами вывода.
+- **Telegram-бот** для удалённого мониторинга и управления.
+- **RESTful API** с документацией Swagger внутри панели.
+- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
+- **13 языков интерфейса** с тёмной и светлой темами.
+- **Интеграция с Fail2ban** для применения лимитов IP по каждому клиенту.
+
+## Скриншоты
+
+<details>
+<summary>Нажмите, чтобы развернуть</summary>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
+  <img alt="Overview" src="./media/01-overview-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
+  <img alt="Inbounds" src="./media/02-add-inbound-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
+  <img alt="Add client" src="./media/03-add-client-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
+  <img alt="Configs" src="./media/05-add-nodes-light.png">
+</picture>
+
+</details>
 
 ## Быстрый старт
 
-```
+```bash
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
+Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
+
 Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
 
+## Поддерживаемые платформы
+
+**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine и Windows.
+
+**Архитектуры:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
+
+## Варианты базы данных
+
+3X-UI поддерживает два бэкенда, выбираемых при установке:
+
+- **SQLite** (по умолчанию) — единый файл по пути `/etc/x-ui/x-ui.db`. Без настройки, идеально для небольших и средних развёртываний.
+- **PostgreSQL** — рекомендуется при большом числе клиентов или конфигурациях с несколькими узлами. Установщик может установить PostgreSQL локально за вас или принять DSN к существующему серверу.
+
+Во время выполнения бэкенд выбирается через переменные окружения (установщик записывает их за вас в `/etc/default/x-ui`):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### Перенос существующей установки SQLite в PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# затем задайте XUI_DB_TYPE и XUI_DB_DSN в /etc/default/x-ui и перезапустите:
+systemctl restart x-ui
+```
+
+Исходный файл SQLite остаётся нетронутым; удалите его вручную после проверки нового бэкенда.
+
+### Docker
+
+Команда по умолчанию `docker compose up -d` продолжает использовать SQLite. Чтобы запустить со встроенным сервисом PostgreSQL, раскомментируйте две строки переменных окружения `XUI_DB_*` в `docker-compose.yml` и запустите с профилем:
+
+```bash
+docker compose --profile postgres up -d
+```
+
+Образ включает Fail2ban (включён по умолчанию) для применения **лимитов IP** по каждому клиенту. Fail2ban блокирует нарушителей с помощью `iptables`, что требует возможности `NET_ADMIN`. `docker-compose.yml` уже предоставляет её через `cap_add`; если вы вместо этого запускаете контейнер через `docker run`, добавьте возможности самостоятельно, иначе блокировки будут регистрироваться, но никогда не применяться:
+
+```bash
+docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
+```
+
+## Переменные окружения
+
+| Переменная | Описание | По умолчанию |
+| --- | --- | --- |
+| `XUI_DB_TYPE` | Бэкенд базы данных: `sqlite` или `postgres` | `sqlite` |
+| `XUI_DB_DSN` | Строка подключения PostgreSQL (когда `XUI_DB_TYPE=postgres`) | — |
+| `XUI_DB_FOLDER` | Каталог для файла базы данных SQLite | `/etc/x-ui` |
+| `XUI_DB_MAX_OPEN_CONNS` | Максимум открытых соединений (пул PostgreSQL) | — |
+| `XUI_DB_MAX_IDLE_CONNS` | Максимум простаивающих соединений (пул PostgreSQL) | — |
+| `XUI_ENABLE_FAIL2BAN` | Включить применение лимитов IP на основе Fail2ban | `true` |
+| `XUI_LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warning`, `error`) | `info` |
+| `XUI_DEBUG` | Включить режим отладки | `false` |
+
+## Поддерживаемые языки
+
+Интерфейс панели доступен на 13 языках:
+
+English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+
+## Участие в разработке
+
+Вклад приветствуется. Пожалуйста, прочитайте [руководство по участию](/CONTRIBUTING.md), прежде чем открывать issue или pull request.
+
 ## Особая благодарность
 
 - [alireza0](https://github.com/alireza0/)

+ 126 - 12
README.zh_CN.md

@@ -7,29 +7,143 @@
   </picture>
 </p>
 
-[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
-[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
-[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
-[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
-[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
-[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
-[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
+<p align="center">
+  <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
+  <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
+  <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
+  <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
+  <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
+  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
+</p>
 
-**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
+**3X-UI** 是一个先进的开源 Web 控制面板,用于管理 [Xray-core](https://github.com/XTLS/Xray-core) 服务器。它提供简洁、多语言的界面,用于部署、配置和监控各种代理与 VPN 协议——从单台 VPS 到多节点部署
 
-> [!IMPORTANT]
-> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。
+3X-UI 作为原始 X-UI 项目的增强分支(fork),增加了更广泛的协议支持、更好的稳定性、按客户端的流量统计以及许多提升使用体验的功能。
 
-作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
+> [!IMPORTANT]
+> 本项目仅供个人使用。请勿将其用于非法目的,也请勿在生产环境中使用。
+
+## 功能特性
+
+- **多协议入站** — VLESS、VMess、Trojan、Shadowsocks、WireGuard、Hysteria2、HTTP、SOCKS (Mixed)、Dokodemo-door / Tunnel 和 TUN。
+- **现代传输与安全** — TCP (Raw)、mKCP、WebSocket、gRPC、HTTPUpgrade 和 XHTTP,并通过 TLS、XTLS 和 REALITY 加密。
+- **回落 (Fallback)** — 通过 Xray 的 fallback 功能在单个端口上提供多种协议(例如在 443 端口上同时使用 VLESS 和 Trojan)。
+- **按客户端管理** — 流量配额、到期日期、IP 限制、实时在线状态,以及一键分享链接、二维码和订阅。
+- **流量统计** — 按入站、按客户端、按出站统计,并支持重置控制。
+- **多节点支持** — 从单一面板管理并扩展到多台服务器。
+- **出站与路由** — WARP、NordVPN、自定义路由规则、负载均衡器和出站代理链。
+- **内置订阅服务器**,支持多种输出格式。
+- **Telegram 机器人**,用于远程监控和管理。
+- **RESTful API**,带有面板内置的 Swagger 文档。
+- **灵活的存储** — SQLite(默认)或 PostgreSQL。
+- **13 种界面语言**,支持深色和浅色主题。
+- **Fail2ban 集成**,用于强制执行按客户端的 IP 限制。
+
+## 截图
+
+<details>
+<summary>点击展开</summary>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
+  <img alt="Overview" src="./media/01-overview-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
+  <img alt="Inbounds" src="./media/02-add-inbound-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
+  <img alt="Add client" src="./media/03-add-client-light.png">
+</picture>
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
+  <img alt="Configs" src="./media/05-add-nodes-light.png">
+</picture>
+
+</details>
 
 ## 快速开始
 
-```
+```bash
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
+安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
+
 完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
 
+## 支持的平台
+
+**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap)、Alpine 和 Windows。
+
+**架构:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`。
+
+## 数据库选项
+
+3X-UI 支持两种后端,可在安装时选择:
+
+- **SQLite**(默认)— 位于 `/etc/x-ui/x-ui.db` 的单个文件。无需配置,适合中小型部署。
+- **PostgreSQL** — 推荐用于大量客户端或多节点设置。安装程序可以为您在本地安装 PostgreSQL,或接受指向现有服务器的 DSN。
+
+运行时通过环境变量选择后端(安装程序会为您写入 `/etc/default/x-ui`):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### 将现有的 SQLite 安装迁移到 PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# 然后在 /etc/default/x-ui 中设置 XUI_DB_TYPE 和 XUI_DB_DSN 并重启:
+systemctl restart x-ui
+```
+
+源 SQLite 文件保持不变;在确认新后端正常工作后,请手动删除它。
+
+### Docker
+
+默认的 `docker compose up -d` 仍使用 SQLite。若要使用捆绑的 PostgreSQL 服务运行,请取消注释 `docker-compose.yml` 中的两行 `XUI_DB_*` 环境变量,并使用该 profile 启动:
+
+```bash
+docker compose --profile postgres up -d
+```
+
+该镜像捆绑了 Fail2ban(默认启用),用于强制执行按客户端的 **IP 限制**。Fail2ban 使用 `iptables` 封禁违规者,这需要 `NET_ADMIN` 权限。`docker-compose.yml` 已通过 `cap_add` 授予该权限;如果您改用 `docker run` 启动容器,请自行添加这些权限,否则封禁只会被记录而永远不会生效:
+
+```bash
+docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
+```
+
+## 环境变量
+
+| 变量 | 说明 | 默认值 |
+| --- | --- | --- |
+| `XUI_DB_TYPE` | 数据库后端:`sqlite` 或 `postgres` | `sqlite` |
+| `XUI_DB_DSN` | PostgreSQL 连接字符串(当 `XUI_DB_TYPE=postgres` 时) | — |
+| `XUI_DB_FOLDER` | SQLite 数据库文件所在目录 | `/etc/x-ui` |
+| `XUI_DB_MAX_OPEN_CONNS` | 最大打开连接数(PostgreSQL 连接池) | — |
+| `XUI_DB_MAX_IDLE_CONNS` | 最大空闲连接数(PostgreSQL 连接池) | — |
+| `XUI_ENABLE_FAIL2BAN` | 启用基于 Fail2ban 的 IP 限制 | `true` |
+| `XUI_LOG_LEVEL` | 日志级别(`debug`、`info`、`warning`、`error`) | `info` |
+| `XUI_DEBUG` | 启用调试模式 | `false` |
+
+## 支持的语言
+
+面板界面提供 13 种语言:
+
+English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+
+## 贡献
+
+欢迎贡献。在提交 issue 或 pull request 之前,请阅读[贡献指南](/CONTRIBUTING.md)。
+
 ## 特别感谢
 
 - [alireza0](https://github.com/alireza0/)

+ 1 - 1
config/version

@@ -1 +1 @@
-3.2.5
+3.2.6

+ 1 - 0
database/db.go

@@ -72,6 +72,7 @@ func initModels() error {
 		&model.ClientInbound{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
+		&model.NodeClientTraffic{},
 	}
 	for _, mdl := range models {
 		if err := db.AutoMigrate(mdl); err != nil {

+ 1 - 1
database/db_seed_test.go

@@ -133,7 +133,7 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
 	}
 
 	subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`)
-	for i := 0; i < 2; i++ {
+	for i := range 2 {
 		obj := clients[i].(map[string]any)
 		sub, _ := obj["subId"].(string)
 		if !subIdPattern.MatchString(sub) {

+ 31 - 12
database/migrate_data.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path"
 	"reflect"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -36,6 +37,7 @@ func migrationModels() []any {
 		&model.ClientRecord{},
 		&model.ClientInbound{},
 		&model.InboundFallback{},
+		&model.NodeClientTraffic{},
 	}
 }
 
@@ -102,25 +104,42 @@ func MigrateData(srcPath, dstDSN string) error {
 	return nil
 }
 
-// copyTable streams every row of `mdl` from src to dst in batches.
 func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
+	const batchSize = 500
+
 	sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
-	batchPtr := reflect.New(sliceType)
-	batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
+
+	// Resolve primary-key columns so paging is deterministic across successive
+	// LIMIT/OFFSET reads. The model set is trusted (not user input).
+	stmt := &gorm.Statement{DB: src}
+	if err := stmt.Parse(mdl); err != nil {
+		return 0, err
+	}
+	order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
 
 	total := 0
-	err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
-		batch := batchPtr.Elem()
-		if batch.Len() == 0 {
-			return nil
+	for offset := 0; ; offset += batchSize {
+		batchPtr := reflect.New(sliceType)
+		q := src.Model(mdl).Limit(batchSize).Offset(offset)
+		if order != "" {
+			q = q.Order(order)
+		}
+		if err := q.Find(batchPtr.Interface()).Error; err != nil {
+			return total, err
+		}
+		n := batchPtr.Elem().Len()
+		if n == 0 {
+			break
 		}
 		if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
-			return err
+			return total, err
+		}
+		total += n
+		if n < batchSize {
+			break
 		}
-		total += batch.Len()
-		return nil
-	}).Error
-	return total, err
+	}
+	return total, nil
 }
 
 // resetPostgresSequences advances each migrated table's id sequence past MAX(id),

+ 64 - 0
database/migrate_data_test.go

@@ -0,0 +1,64 @@
+package database
+
+import (
+	"os"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+func TestMigrateData_CompositeKeyTableLargerThanBatch(t *testing.T) {
+	dsn := os.Getenv("XUI_TEST_PG_DSN")
+	if dsn == "" {
+		t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
+	}
+
+	// Seed a SQLite source with the full schema and >500 client_inbounds rows.
+	srcPath := t.TempDir() + "/x-ui.db"
+	src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open sqlite: %v", err)
+	}
+	for _, m := range migrationModels() {
+		if err := src.AutoMigrate(m); err != nil {
+			t.Fatalf("automigrate %T: %v", m, err)
+		}
+	}
+	const n = 600 // > batchSize (500) so the between-batches path is exercised
+	links := make([]model.ClientInbound, 0, n)
+	for i := 1; i <= n; i++ {
+		links = append(links, model.ClientInbound{ClientId: i, InboundId: 1})
+	}
+	if err := src.CreateInBatches(links, 200).Error; err != nil {
+		t.Fatalf("seed client_inbounds: %v", err)
+	}
+	if sqlDB, err := src.DB(); err == nil {
+		sqlDB.Close() // flush before MigrateData reopens the file
+	}
+
+	// Make the test re-runnable: drop any tables from a previous run.
+	dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open postgres: %v", err)
+	}
+	if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
+		t.Fatalf("drop tables: %v", err)
+	}
+
+	if err := MigrateData(srcPath, dsn); err != nil {
+		t.Fatalf("MigrateData: %v", err) // fails here before the fix
+	}
+
+	var got int64
+	if err := dst.Model(&model.ClientInbound{}).Count(&got).Error; err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if got != n {
+		t.Fatalf("client_inbounds rows = %d, want %d", got, n)
+	}
+}

+ 4 - 1
database/model/model.go

@@ -55,7 +55,7 @@ type Inbound struct {
 
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
-	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	Port           int      `json:"port" form:"port" validate:"gte=0,lte=65535"`
 	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
@@ -307,6 +307,7 @@ func StripVlessInboundEncryption(settings string) (string, bool) {
 //     inbound with "users must have empty method" when a client carries
 //     one — strip stale entries left over from a switch off a legacy
 //     cipher.
+//
 // Returns the rewritten settings string and true when anything changed.
 func HealShadowsocksClientMethods(settings string) (string, bool) {
 	if settings == "" {
@@ -379,6 +380,8 @@ type Node struct {
 	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required"`
 	Enable              bool   `json:"enable" form:"enable" gorm:"default:true"`
 	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
+	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
+	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 
 	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
 	// the row is otherwise unchanged so the UI's "last seen" tooltip is

+ 9 - 0
database/model/node_client_traffic.go

@@ -0,0 +1,9 @@
+package model
+
+type NodeClientTraffic struct {
+	Id     int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	NodeId int    `json:"nodeId" gorm:"uniqueIndex:idx_node_email,priority:1;not null"`
+	Email  string `json:"email" gorm:"uniqueIndex:idx_node_email,priority:2;not null"`
+	Up     int64  `json:"up"`
+	Down   int64  `json:"down"`
+}

+ 50 - 0
frontend/public/openapi.json

@@ -4203,6 +4203,56 @@
         }
       }
     },
+    "/panel/api/nodes/certFingerprint": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
+        "operationId": "post_panel_api_nodes_certFingerprint",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "scheme": "https",
+                "address": "node1.example.com",
+                "port": 2053,
+                "basePath": "/"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": "k3b1...base64-sha256...="
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/probe/{id}": {
       "post": {
         "tags": [

+ 2 - 0
frontend/src/api/queries/useNodeMutations.ts

@@ -70,5 +70,7 @@ export function useNodeMutations() {
       const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
       return parseMsg(raw, ProbeResultSchema, 'nodes/test');
     },
+    fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
+      HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
   };
 }

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

@@ -333,10 +333,12 @@ export interface Node {
   name: string;
   onlineCount: number;
   panelVersion: string;
+  pinnedCertSha256: string;
   port: number;
   remark: string;
   scheme: string;
   status: string;
+  tlsVerifyMode: string;
   updatedAt: number;
   uptimeSecs: number;
   xrayVersion: string;

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

@@ -291,7 +291,7 @@ export const InboundSchema = z.object({
   lastTrafficResetTime: z.number().int(),
   listen: z.string(),
   nodeId: z.number().int().nullable().optional(),
-  port: z.number().int().min(1).max(65535),
+  port: z.number().int().min(0).max(65535),
   protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
   remark: z.string(),
   settings: z.unknown(),
@@ -350,10 +350,12 @@ export const NodeSchema = z.object({
   name: z.string(),
   onlineCount: z.number().int(),
   panelVersion: z.string(),
+  pinnedCertSha256: z.string(),
   port: z.number().int().min(1).max(65535),
   remark: z.string(),
   scheme: z.enum(['http', 'https']),
   status: z.string(),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
   updatedAt: z.number().int(),
   uptimeSecs: z.number().int(),
   xrayVersion: z.string(),

+ 15 - 7
frontend/src/hooks/useXraySetting.ts

@@ -197,13 +197,21 @@ export function useXraySetting(): UseXraySettingResult {
   }, [queryClient]);
 
   const saveMut = useMutation({
-    mutationFn: async () =>
-      HttpUtil.post('/panel/xray/update', {
-        xraySetting: xraySettingRef.current,
-        outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
-      }),
-    onSuccess: (msg) => {
-      if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
+    mutationFn: async () => {
+      const sentXraySetting = xraySettingRef.current;
+      const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
+      const msg = await HttpUtil.post('/panel/xray/update', {
+        xraySetting: sentXraySetting,
+        outboundTestUrl: sentTestUrl,
+      });
+      return { msg, sentXraySetting, sentTestUrl };
+    },
+    onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
+      if (!msg?.success) return;
+      oldXraySettingRef.current = sentXraySetting;
+      oldOutboundTestUrlRef.current = sentTestUrl;
+      setSaveDisabled(true);
+      queryClient.invalidateQueries({ queryKey: keys.xray.config() });
     },
   });
 

+ 9 - 2
frontend/src/lib/xray/inbound-link.ts

@@ -706,15 +706,22 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
 
 export type { WireguardInboundPeer };
 
+function isUnixSocketListen(listen: string): boolean {
+  return listen.startsWith('/') || listen.startsWith('@');
+}
+
 // Orchestrators.
 // resolveAddr picks the host that goes into share/sub links. Order:
 //   1. hostOverride (caller supplies node address for node-managed inbounds)
-//   2. inbound's bind listen (when explicit, not 0.0.0.0)
+//   2. inbound's bind listen (when it's an explicit reachable address —
+//      not 0.0.0.0 and not a unix domain socket path)
 //   3. fallbackHostname (caller-supplied — typically window.location.hostname
 //      in the browser; tests pass a fixed value)
 export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
   if (hostOverride.length > 0) return hostOverride;
-  if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen;
+  if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) {
+    return inbound.listen;
+  }
   return fallbackHostname;
 }
 

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -769,6 +769,13 @@ export const sections: readonly Section[] = [
         body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "latencyMs": 42,\n    "xrayVersion": "25.x.x",\n    "panelVersion": "v3.x.x",\n    "cpuPct": 12.5,\n    "memPct": 45.2,\n    "uptimeSecs": 86400,\n    "error": ""\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/certFingerprint',
+        summary: "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
+        body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/"\n}',
+        response: '{\n  "success": true,\n  "obj": "k3b1...base64-sha256...="\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/nodes/probe/:id',

+ 2 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -390,6 +390,8 @@ export default function ClientFormModal({
         cancelText={t('cancel')}
         okButtonProps={{ loading: submitting }}
         width={720}
+        style={{ top: 20 }}
+        styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
         onOk={onSubmit}
         onCancel={close}
       >

+ 2 - 1
frontend/src/pages/inbounds/form/FallbacksCard.tsx

@@ -44,12 +44,13 @@ export default function FallbacksCard({
               value={record.childId}
               options={fallbackChildOptions}
               placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
+              allowClear
               showSearch={{
                 filterOption: (input, option) =>
                   ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
               }}
               style={{ width: '100%' }}
-              onChange={(v) => updateFallback(record.rowKey, { childId: v })}
+              onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
             />
             <Button
               disabled={idx === 0}

+ 8 - 2
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -161,6 +161,8 @@ export default function InboundFormModal({
   const streamEnabled = canEnableStream({ protocol });
 
   const wPort = Form.useWatch('port', form);
+  const wListen = (Form.useWatch('listen', form) ?? '') as string;
+  const isUdsListen = wListen.startsWith('/');
   const wNodeId = Form.useWatch('nodeId', form) ?? null;
   const wTag = Form.useWatch('tag', form) ?? '';
   const wSsNetwork = Form.useWatch(['settings', 'network'], form);
@@ -479,7 +481,11 @@ export default function InboundFormModal({
         <Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
       </Form.Item>
 
-      <Form.Item name="listen" label={t('pages.inbounds.address')}>
+      <Form.Item
+        name="listen"
+        label={t('pages.inbounds.address')}
+        extra={t('pages.inbounds.form.listenHelp')}
+      >
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
       </Form.Item>
 
@@ -488,7 +494,7 @@ export default function InboundFormModal({
         label={t('pages.inbounds.port')}
         rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
       >
-        <InputNumber min={1} max={65535} />
+        <InputNumber min={isUdsListen ? 0 : 1} max={65535} />
       </Form.Item>
 
       <Form.Item

+ 0 - 7
frontend/src/pages/inbounds/form/transport/raw.tsx

@@ -56,13 +56,6 @@ export default function RawForm() {
           }}
         </Form.Item>
       </Form.Item>
-      {/* Per Xray docs (transports/raw.html#httpheaderobject), the
-          `request` object is honored only by outbound proxies; the
-          inbound listener reads `response`. Showing Host / Path /
-          Method / Version / request-headers on the inbound side was
-          a regression from this modal's earlier iteration — those
-          inputs wrote to the wire but xray-core ignored them. The
-          inbound modal now only exposes the response side. */}
       <Form.Item
         noStyle
         shouldUpdate={(prev, curr) =>

+ 2 - 2
frontend/src/pages/inbounds/form/useInboundFallbacks.ts

@@ -39,7 +39,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
       }[])
         .map((r) => ({
           rowKey: `fb-${++fallbackKeyRef.current}`,
-          childId: r.childId,
+          childId: r.childId && r.childId > 0 ? r.childId : null,
           name: r.name || '',
           alpn: r.alpn || '',
           path: r.path || '',
@@ -52,7 +52,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
   const saveFallbacks = async (masterId: number) => {
     if (!masterId) return true;
     const payload = {
-      fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({
+      fallbacks: fallbacks.filter((c) => c.childId || (c.dest ?? '').trim()).map((c, i) => ({
         childId: c.childId,
         name: c.name,
         alpn: c.alpn,

+ 67 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -26,6 +26,7 @@ interface NodeFormModalProps {
   mode: Mode;
   node: NodeRecord | null;
   testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
+  fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
   save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
   onOpenChange: (open: boolean) => void;
 }
@@ -42,6 +43,8 @@ function defaultValues(): NodeFormValues {
     apiToken: '',
     enable: true,
     allowPrivateAddress: false,
+    tlsVerifyMode: 'verify',
+    pinnedCertSha256: '',
   };
 }
 
@@ -50,6 +53,7 @@ export default function NodeFormModal({
   mode,
   node,
   testConnection,
+  fetchFingerprint,
   save,
   onOpenChange,
 }: NodeFormModalProps) {
@@ -59,7 +63,9 @@ export default function NodeFormModal({
 
   const [submitting, setSubmitting] = useState(false);
   const [testing, setTesting] = useState(false);
+  const [fetchingPin, setFetchingPin] = useState(false);
   const [testResult, setTestResult] = useState<ProbeResult | null>(null);
+  const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
 
   useEffect(() => {
     if (!open) return;
@@ -94,6 +100,8 @@ export default function NodeFormModal({
       apiToken: values.apiToken.trim(),
       enable: values.enable,
       allowPrivateAddress: values.allowPrivateAddress,
+      tlsVerifyMode: values.tlsVerifyMode,
+      pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
     };
   }
 
@@ -118,6 +126,27 @@ export default function NodeFormModal({
     }
   }
 
+  async function onFetchPin() {
+    try {
+      await form.validateFields(['address', 'port']);
+    } catch {
+      return;
+    }
+    setFetchingPin(true);
+    try {
+      const payload = buildPayload(form.getFieldsValue(true));
+      const msg = await fetchFingerprint(payload);
+      if (msg?.success && msg.obj) {
+        form.setFieldValue('pinnedCertSha256', msg.obj);
+        messageApi.success(t('pages.nodes.pinFetched'));
+      } else {
+        messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
+      }
+    } finally {
+      setFetchingPin(false);
+    }
+  }
+
   async function onFinish(values: NodeFormValues) {
     const result = NodeFormSchema.safeParse(values);
     if (!result.success) {
@@ -233,6 +262,44 @@ export default function NodeFormModal({
             <Switch />
           </Form.Item>
 
+          <Form.Item
+            label={t('pages.nodes.tlsVerifyMode')}
+            name="tlsVerifyMode"
+            extra={t('pages.nodes.tlsVerifyModeHint')}
+          >
+            <Select
+              options={[
+                { value: 'verify', label: t('pages.nodes.tlsVerify') },
+                { value: 'pin', label: t('pages.nodes.tlsPin') },
+                { value: 'skip', label: t('pages.nodes.tlsSkip') },
+              ]}
+            />
+          </Form.Item>
+
+          {tlsVerifyMode === 'skip' && (
+            <Alert
+              type="warning"
+              showIcon
+              style={{ marginBottom: 16 }}
+              title={t('pages.nodes.tlsSkipWarning')}
+            />
+          )}
+
+          {tlsVerifyMode === 'pin' && (
+            <Form.Item
+              label={t('pages.nodes.pinnedCert')}
+              name="pinnedCertSha256"
+              extra={t('pages.nodes.pinnedCertHint')}
+            >
+              <Input.Search
+                placeholder={t('pages.nodes.pinnedCertPlaceholder')}
+                enterButton={t('pages.nodes.fetchPin')}
+                loading={fetchingPin}
+                onSearch={onFetchPin}
+              />
+            </Form.Item>
+          )}
+
           <Form.Item
             label={t('pages.nodes.apiToken')}
             name="apiToken"

+ 2 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -30,7 +30,7 @@ export default function NodesPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
   const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
-  const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
+  const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
 
   const { data: latestVersion = '' } = useQuery({
     queryKey: ['server', 'panelUpdateInfo'],
@@ -231,6 +231,7 @@ export default function NodesPage() {
           mode={formMode}
           node={formNode}
           testConnection={testConnection}
+          fetchFingerprint={fetchFingerprint}
           save={onSave}
           onOpenChange={setFormOpen}
         />

+ 3 - 1
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -575,7 +575,9 @@ export default function OutboundFormModal({
 
                     {security === 'reality' && realityAllowed && <RealityForm />}
 
-                    {((streamAllowed && network) || !streamAllowed) && <SockoptForm form={form} />}
+                    {((streamAllowed && network) || !streamAllowed) && (
+                      <SockoptForm form={form} outboundTags={existingTags} />
+                    )}
 
                     <FinalMaskForm
                       name={['streamSettings', 'finalmask']}

+ 18 - 3
frontend/src/pages/xray/outbounds/transport/raw.tsx

@@ -47,6 +47,15 @@ export default function RawForm({ form }: { form: FormInstance<OutboundFormValue
             </Form.Item>
             {type === 'http' && (
               <>
+                <Form.Item
+                  label={t('pages.inbounds.form.requestVersion')}
+                  name={[
+                    'streamSettings', 'tcpSettings', 'header',
+                    'request', 'version',
+                  ]}
+                >
+                  <Input placeholder="1.1" />
+                </Form.Item>
                 <Form.Item
                   label={t('pages.inbounds.form.requestMethod')}
                   name={[
@@ -57,13 +66,19 @@ export default function RawForm({ form }: { form: FormInstance<OutboundFormValue
                   <Input placeholder="GET" />
                 </Form.Item>
                 <Form.Item
-                  label={t('pages.inbounds.form.requestVersion')}
+                  label={t('pages.inbounds.form.requestPath')}
                   name={[
                     'streamSettings', 'tcpSettings', 'header',
-                    'request', 'version',
+                    'request', 'path',
                   ]}
+                  getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
+                  getValueFromEvent={(e) => {
+                    const raw = (e?.target?.value ?? '') as string;
+                    const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
+                    return parts.length > 0 ? parts : ['/'];
+                  }}
                 >
-                  <Input placeholder="1.1" />
+                  <Input placeholder="/" />
                 </Form.Item>
                 <Form.Item
                   label={t('pages.inbounds.form.requestHeaders')}

+ 22 - 2
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -7,7 +7,13 @@ import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 
 import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
 
-export default function SockoptForm({ form }: { form: FormInstance<OutboundFormValues> }) {
+export default function SockoptForm({
+  form,
+  outboundTags = [],
+}: {
+  form: FormInstance<OutboundFormValues>;
+  outboundTags?: string[];
+}) {
   const { t } = useTranslation();
   return (
     <Form.Item shouldUpdate noStyle>
@@ -16,6 +22,14 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
           'streamSettings',
           'sockopt',
         ]);
+        const dialerProxy = (form.getFieldValue([
+          'streamSettings',
+          'sockopt',
+          'dialerProxy',
+        ]) ?? '') as string;
+        const dialerProxyOptions = Array.from(
+          new Set([...outboundTags, dialerProxy].filter(Boolean)),
+        ).map((tg) => ({ value: tg, label: tg }));
         return (
           <>
             <Form.Item label={t('pages.xray.outboundForm.sockopts')}>
@@ -34,8 +48,14 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
                 <Form.Item
                   label={t('pages.inbounds.form.dialerProxy')}
                   name={['streamSettings', 'sockopt', 'dialerProxy']}
+                  tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
                 >
-                  <Input />
+                  <Select
+                    allowClear
+                    showSearch
+                    placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
+                    options={dialerProxyOptions}
+                  />
                 </Form.Item>
                 <Form.Item
                   label={t('pages.xray.wireguard.domainStrategy')}

+ 2 - 1
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -38,6 +38,7 @@ interface WarpConfig {
   model?: string;
   enabled?: boolean;
   config?: {
+    client_id?: string;
     interface?: { addresses?: { v4?: string; v6?: string } };
     peers?: { public_key?: string; endpoint?: { host?: string } }[];
   };
@@ -99,7 +100,7 @@ export default function WarpModal({
         mtu: 1420,
         secretKey: data?.private_key,
         address: addressesFor(cfg.interface?.addresses || {}),
-        reserved: reservedFor(data?.client_id),
+        reserved: reservedFor(cfg.client_id ?? data?.client_id),
         domainStrategy: 'ForceIP',
         peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
         noKernelTun: false,

+ 2 - 2
frontend/src/schemas/api/inbound.ts

@@ -1,6 +1,6 @@
 import { z } from 'zod';
 
-import { PortSchema, SniffingSchema } from '@/schemas/primitives';
+import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
 import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
 import { SecuritySettingsSchema } from '@/schemas/protocols/security';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
@@ -32,7 +32,7 @@ export const InboundCoreSchema = z.object({
   enable: z.boolean().default(true),
   expiryTime: z.number().int().default(0),
   listen: z.string().default(''),
-  port: PortSchema,
+  port: InboundPortSchema,
   tag: z.string().default(''),
   sniffing: SniffingSchema.default({
     enabled: false,

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

@@ -1,6 +1,6 @@
 import { z } from 'zod';
 
-import { PortSchema, SniffingSchema } from '@/schemas/primitives';
+import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
 import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
 import { SecuritySettingsSchema } from '@/schemas/protocols/security';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
@@ -44,7 +44,7 @@ export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
 export const InboundFormBaseSchema = z.object({
   remark: z.string().default(''),
   enable: z.boolean().default(true),
-  port: PortSchema,
+  port: InboundPortSchema,
   listen: z.string().default(''),
   tag: z.string().default(''),
   expiryTime: z.number().int().default(0),

+ 4 - 0
frontend/src/schemas/node.ts

@@ -24,6 +24,8 @@ export const NodeRecordSchema = z.object({
   lastHeartbeat: z.number().optional(),
   lastError: z.string().optional(),
   allowPrivateAddress: z.boolean().optional(),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
+  pinnedCertSha256: z.string().optional(),
 }).loose();
 
 export const NodeListSchema = z.array(NodeRecordSchema);
@@ -46,6 +48,8 @@ export const NodeFormSchema = z.object({
   apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
   enable: z.boolean(),
   allowPrivateAddress: z.boolean(),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
+  pinnedCertSha256: z.string(),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 3 - 0
frontend/src/schemas/primitives/port.ts

@@ -2,3 +2,6 @@ import { z } from 'zod';
 
 export const PortSchema = z.number().int().min(1).max(65535);
 export type Port = z.infer<typeof PortSchema>;
+
+export const InboundPortSchema = z.number().int().min(0).max(65535);
+export type InboundPort = z.infer<typeof InboundPortSchema>;

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

@@ -196,6 +196,19 @@ describe('resolveAddr precedence', () => {
     )).toBe('fallback.test');
   });
 
+  it('skips a unix socket path listen and falls through to fallbackHostname', () => {
+    expect(resolveAddr(
+      { ...baseInbound, listen: '/run/xray/in.sock' } as never,
+      '',
+      'fallback.test',
+    )).toBe('fallback.test');
+    expect(resolveAddr(
+      { ...baseInbound, listen: '@xray-abstract' } as never,
+      '',
+      'fallback.test',
+    )).toBe('fallback.test');
+  });
+
   it('falls through to fallbackHostname when listen is empty', () => {
     expect(resolveAddr(
       baseInbound as never,

BIN
media/01-overview-dark.png


BIN
media/01-overview-light.png


BIN
media/02-add-inbound-dark.png


BIN
media/02-add-inbound-light.png


BIN
media/02-inbounds-dark.png


BIN
media/02-inbounds-light.png


BIN
media/03-add-client-dark.png


BIN
media/03-add-client-light.png


BIN
media/03-add-inbound-dark.png


BIN
media/03-add-inbound-light.png


BIN
media/03-client-dark.png


BIN
media/03-client-light.png


BIN
media/04-add-client-dark.png


BIN
media/04-add-client-light.png


BIN
media/04-group-dark.png


BIN
media/04-group-light.png


BIN
media/05-add-nodes-dark.png


BIN
media/05-add-nodes-light.png


BIN
media/05-nodes-dark.png


BIN
media/05-nodes-light.png


BIN
media/05-settings-dark.png


BIN
media/05-settings-light.png


BIN
media/06-configs-dark.png


BIN
media/06-configs-light.png


BIN
media/06-settings-dark.png


BIN
media/06-settings-light.png


BIN
media/07-configs-dark.png


BIN
media/07-configs-light.png


BIN
media/08-api-docs-dark.png


BIN
media/08-api-docs-light.png


+ 24 - 0
web/controller/node.go

@@ -34,6 +34,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 	g.POST("/setEnable/:id", a.setEnable)
 
 	g.POST("/test", a.test)
+	g.POST("/certFingerprint", a.certFingerprint)
 	g.POST("/probe/:id", a.probe)
 	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
@@ -143,6 +144,29 @@ func (a *NodeController) test(c *gin.Context) {
 	jsonObj(c, patch.ToUI(err == nil), nil)
 }
 
+func (a *NodeController) certFingerprint(c *gin.Context) {
+	n := &model.Node{}
+	if err := c.ShouldBind(n); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
+	}
+	if n.Scheme == "" {
+		n.Scheme = "https"
+	}
+	if n.BasePath == "" {
+		n.BasePath = "/"
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
+	defer cancel()
+	fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
+	}
+	jsonObj(c, fp, nil)
+}
+
 func (a *NodeController) probe(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {

+ 16 - 5
web/job/check_client_ip_job.go

@@ -66,8 +66,12 @@ func (j *CheckClientIpJob) Run() {
 	}
 
 	shouldClearAccessLog := false
-	iplimitActive := j.hasLimitIp()
-	f2bInstalled := j.checkFail2BanInstalled()
+	fail2BanEnabled := isFail2BanEnabled()
+	iplimitActive := fail2BanEnabled && j.hasLimitIp()
+	f2bInstalled := false
+	if iplimitActive {
+		f2bInstalled = j.checkFail2BanInstalled()
+	}
 	isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
 
 	if isAccessLogAvailable {
@@ -80,9 +84,7 @@ func (j *CheckClientIpJob) Run() {
 				if f2bInstalled {
 					shouldClearAccessLog = j.processLogFile()
 				} else {
-					if !f2bInstalled {
-						logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
-					}
+					logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
 				}
 			}
 		}
@@ -279,12 +281,21 @@ func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool)
 }
 
 func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
+	if !isFail2BanEnabled() {
+		return false
+	}
+
 	cmd := "fail2ban-client"
 	args := []string{"-h"}
 	err := exec.Command(cmd, args...).Run()
 	return err == nil
 }
 
+func isFail2BanEnabled() bool {
+	value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
+	return !ok || value == "true"
+}
+
 func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
 	accessLogPath, err := xray.GetAccessLogPath()
 	if err != nil {

+ 43 - 0
web/job/check_client_ip_job_integration_test.go

@@ -128,6 +128,49 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
 	return out
 }
 
+func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
+	setupIntegrationDB(t)
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
+	marker := fakeFail2BanClient(t)
+
+	const email = "disabled-fail2ban"
+	seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
+
+	binDir := t.TempDir()
+	accessLog := filepath.Join(t.TempDir(), "access.log")
+	t.Setenv("XUI_BIN_FOLDER", binDir)
+	configData, err := json.Marshal(map[string]any{
+		"log": map[string]any{"access": accessLog},
+	})
+	if err != nil {
+		t.Fatalf("marshal xray config: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
+		t.Fatalf("write xray config: %v", err)
+	}
+	if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
+		t.Fatalf("write access log: %v", err)
+	}
+
+	j := NewCheckClientIpJob()
+	j.Run()
+
+	if _, err := os.Stat(marker); !os.IsNotExist(err) {
+		t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
+	}
+	if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
+		body, _ := os.ReadFile(readIpLimitLogPath())
+		t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
+		t.Fatalf("count InboundClientIps: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
+	}
+}
+
 // #4091 repro: client has limit=3, db still holds 3 idle ips from a
 // few minutes ago, only one live ip is actually connecting. pre-fix:
 // live ip got banned every tick and never appeared in the panel.

+ 75 - 0
web/job/check_client_ip_job_test.go

@@ -1,7 +1,10 @@
 package job
 
 import (
+	"os"
+	"path/filepath"
 	"reflect"
+	"runtime"
 	"testing"
 )
 
@@ -145,3 +148,75 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
 		t.Fatalf("all merged entries should flow to historical\ngot:  %v\nwant: [A B]", got)
 	}
 }
+
+func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
+	marker := fakeFail2BanClient(t)
+
+	if (&CheckClientIpJob{}).checkFail2BanInstalled() {
+		t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
+	}
+	if _, err := os.Stat(marker); !os.IsNotExist(err) {
+		t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
+	}
+}
+
+func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "")
+	marker := fakeFail2BanClient(t)
+
+	if (&CheckClientIpJob{}).checkFail2BanInstalled() {
+		t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
+	}
+	if _, err := os.Stat(marker); !os.IsNotExist(err) {
+		t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
+	}
+}
+
+func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
+	value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
+	os.Unsetenv("XUI_ENABLE_FAIL2BAN")
+	t.Cleanup(func() {
+		if ok {
+			os.Setenv("XUI_ENABLE_FAIL2BAN", value)
+		} else {
+			os.Unsetenv("XUI_ENABLE_FAIL2BAN")
+		}
+	})
+
+	if !isFail2BanEnabled() {
+		t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
+	}
+}
+
+func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
+	marker := fakeFail2BanClient(t)
+
+	if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
+		t.Fatal("fail2ban should be available when the client probe succeeds")
+	}
+	if _, err := os.Stat(marker); err != nil {
+		t.Fatalf("fail2ban-client should have been executed: %v", err)
+	}
+}
+
+func fakeFail2BanClient(t *testing.T) string {
+	t.Helper()
+
+	dir := t.TempDir()
+	marker := filepath.Join(dir, "probe-called")
+	fakeClient := filepath.Join(dir, "fail2ban-client")
+	script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
+	if runtime.GOOS == "windows" {
+		fakeClient += ".bat"
+		script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
+	}
+	if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
+		t.Fatalf("write fake fail2ban-client: %v", err)
+	}
+
+	t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
+	t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+	return marker
+}

+ 244 - 0
web/service/bulk_clients_test.go

@@ -0,0 +1,244 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"sort"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func setupBulkDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func clientsSettings(t *testing.T, clients []model.Client) string {
+	t.Helper()
+	b, err := json.Marshal(map[string][]model.Client{"clients": clients})
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+	return string(b)
+}
+
+func emailsOf(clients []model.Client) []string {
+	out := make([]string, 0, len(clients))
+	for _, c := range clients {
+		out = append(out, c.Email)
+	}
+	return out
+}
+
+func sortedEmails(list []model.Client) []string {
+	out := emailsOf(list)
+	sort.Strings(out)
+	return out
+}
+
+func mkInbound(t *testing.T, port int, proto model.Protocol, settings string) *model.Inbound {
+	t.Helper()
+	ib := &model.Inbound{
+		Tag:      string(proto) + "-" + filepath.Base(t.TempDir()),
+		Enable:   true,
+		Port:     port,
+		Protocol: proto,
+		Settings: settings,
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound %d: %v", port, err)
+	}
+	return ib
+}
+
+// TestBulkAttachDetach_VLESS exercises the batched attach/detach round-trip on
+// VLESS inbounds: linkage, settings JSON, idempotency, skip, and record survival.
+func TestBulkAttachDetach_VLESS(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+		{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+		{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+	}
+
+	ib1 := mkInbound(t, 20001, model.VLESS, clientsSettings(t, source))
+	ib2 := mkInbound(t, 20002, model.VLESS, `{"clients":[]}`)
+	ib3 := mkInbound(t, 20003, model.VLESS, `{"clients":[]}`)
+
+	if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
+		t.Fatalf("seed source linkage: %v", err)
+	}
+
+	emails := emailsOf(source)
+
+	res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
+	if err != nil {
+		t.Fatalf("BulkAttach: %v", err)
+	}
+	if len(res.Errors) != 0 {
+		t.Fatalf("BulkAttach errors: %v", res.Errors)
+	}
+	if len(res.Skipped) != 0 {
+		t.Fatalf("BulkAttach skipped unexpectedly: %v", res.Skipped)
+	}
+	if len(res.Attached) != 6 {
+		t.Fatalf("expected 6 attach entries (3 clients x 2 inbounds), got %d: %v", len(res.Attached), res.Attached)
+	}
+
+	for _, ib := range []*model.Inbound{ib2, ib3} {
+		list, err := svc.ListForInbound(nil, ib.Id)
+		if err != nil {
+			t.Fatalf("ListForInbound(%d): %v", ib.Id, err)
+		}
+		if got := sortedEmails(list); len(got) != 3 {
+			t.Fatalf("inbound %d: expected 3 linked clients, got %v", ib.Id, got)
+		}
+		reloaded, err := inboundSvc.GetInbound(ib.Id)
+		if err != nil {
+			t.Fatalf("GetInbound(%d): %v", ib.Id, err)
+		}
+		jsonClients, err := inboundSvc.GetClients(reloaded)
+		if err != nil {
+			t.Fatalf("GetClients(%d): %v", ib.Id, err)
+		}
+		if len(jsonClients) != 3 {
+			t.Fatalf("inbound %d settings JSON: expected 3 clients, got %d", ib.Id, len(jsonClients))
+		}
+	}
+
+	res2, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
+	if err != nil {
+		t.Fatalf("BulkAttach (idempotent): %v", err)
+	}
+	if len(res2.Attached) != 0 {
+		t.Fatalf("re-attach should add nothing, got Attached=%v", res2.Attached)
+	}
+	if len(res2.Skipped) != 6 {
+		t.Fatalf("re-attach should skip all 6, got Skipped=%v", res2.Skipped)
+	}
+
+	dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
+	if err != nil {
+		t.Fatalf("BulkDetach: %v", err)
+	}
+	if len(dres.Errors) != 0 {
+		t.Fatalf("BulkDetach errors: %v", dres.Errors)
+	}
+	if len(dres.Detached) != 3 {
+		t.Fatalf("expected 3 detached emails, got %v", dres.Detached)
+	}
+
+	for _, ib := range []*model.Inbound{ib2, ib3} {
+		list, err := svc.ListForInbound(nil, ib.Id)
+		if err != nil {
+			t.Fatalf("ListForInbound after detach(%d): %v", ib.Id, err)
+		}
+		if len(list) != 0 {
+			t.Fatalf("inbound %d should have no clients after detach, got %v", ib.Id, sortedEmails(list))
+		}
+		reloaded, _ := inboundSvc.GetInbound(ib.Id)
+		jsonClients, _ := inboundSvc.GetClients(reloaded)
+		if len(jsonClients) != 0 {
+			t.Fatalf("inbound %d settings JSON should be empty after detach, got %d", ib.Id, len(jsonClients))
+		}
+	}
+
+	for _, e := range emails {
+		rec, err := svc.GetRecordByEmail(nil, e)
+		if err != nil {
+			t.Fatalf("record %q should survive detach: %v", e, err)
+		}
+		ids, err := svc.GetInboundIdsForRecord(rec.Id)
+		if err != nil {
+			t.Fatalf("GetInboundIdsForRecord(%q): %v", e, err)
+		}
+		if len(ids) != 1 || ids[0] != ib1.Id {
+			t.Fatalf("record %q should remain attached only to source inbound %d, got %v", e, ib1.Id, ids)
+		}
+	}
+}
+
+// TestBulkDetach_SkipsUnattached verifies emails not on any requested inbound
+// land in Skipped, not Detached, and produce no error.
+func TestBulkDetach_SkipsUnattached(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "only-on-1@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "s1", Enable: true},
+	}
+	ib1 := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
+	ib2 := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
+	if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	dres, restart, err := svc.BulkDetach(inboundSvc, []string{"only-on-1@x"}, []int{ib2.Id})
+	if err != nil {
+		t.Fatalf("BulkDetach: %v", err)
+	}
+	if restart {
+		t.Fatalf("no-op detach should not require restart")
+	}
+	if len(dres.Detached) != 0 {
+		t.Fatalf("nothing should be detached, got %v", dres.Detached)
+	}
+	if len(dres.Skipped) != 1 || dres.Skipped[0] != "only-on-1@x" {
+		t.Fatalf("expected the email in Skipped, got %v", dres.Skipped)
+	}
+	if len(dres.Errors) != 0 {
+		t.Fatalf("unexpected errors: %v", dres.Errors)
+	}
+}
+
+// TestBulkAttachDetach_Trojan checks the protocol-specific key matching in the
+// batched detach path (Trojan keys on password, not id).
+func TestBulkAttachDetach_Trojan(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "t1@x", Password: "pw-t1", SubID: "t1", Enable: true},
+		{Email: "t2@x", Password: "pw-t2", SubID: "t2", Enable: true},
+	}
+	ib1 := mkInbound(t, 22001, model.Trojan, clientsSettings(t, source))
+	ib2 := mkInbound(t, 22002, model.Trojan, `{"clients":[]}`)
+	if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	emails := emailsOf(source)
+	if res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id}); err != nil {
+		t.Fatalf("BulkAttach: %v", err)
+	} else if len(res.Errors) != 0 || len(res.Attached) != 2 {
+		t.Fatalf("attach result unexpected: attached=%v errors=%v", res.Attached, res.Errors)
+	}
+
+	list, _ := svc.ListForInbound(nil, ib2.Id)
+	if len(list) != 2 {
+		t.Fatalf("expected 2 trojan clients on ib2, got %v", sortedEmails(list))
+	}
+
+	dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id})
+	if err != nil {
+		t.Fatalf("BulkDetach: %v", err)
+	}
+	if len(dres.Detached) != 2 || len(dres.Errors) != 0 {
+		t.Fatalf("detach result unexpected: detached=%v errors=%v", dres.Detached, dres.Errors)
+	}
+	if list, _ := svc.ListForInbound(nil, ib2.Id); len(list) != 0 {
+		t.Fatalf("trojan clients should be gone from ib2, got %v", sortedEmails(list))
+	}
+}

+ 199 - 18
web/service/client.go

@@ -245,10 +245,7 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 			if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
 				row.CreatedAt = incoming.CreatedAt
 			}
-			preservedUpdatedAt := row.UpdatedAt
-			if incoming.UpdatedAt > preservedUpdatedAt {
-				preservedUpdatedAt = incoming.UpdatedAt
-			}
+			preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
 			row.UpdatedAt = preservedUpdatedAt
 			if err := tx.Save(row).Error; err != nil {
 				return err
@@ -886,6 +883,12 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
 		records = append(records, rec)
 	}
 
+	emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
+	if sidErr != nil {
+		emailSubIDs = nil
+		logger.Warningf("[BulkAttach] getAllEmailSubIDs: %v", sidErr)
+	}
+
 	needRestart := false
 	for _, ibId := range inboundIds {
 		inbound, err := inboundSvc.GetInbound(ibId)
@@ -927,7 +930,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
 			recordErr("inbound %d: %v", ibId, err)
 			continue
 		}
-		nr, err := s.AddInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)})
+		nr, err := s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
 		if err != nil {
 			recordErr("inbound %d: %v", ibId, err)
 			continue
@@ -972,7 +975,10 @@ func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string,
 		requested[id] = struct{}{}
 	}
 
-	needRestart := false
+	recsByInbound := make(map[int][]*model.ClientRecord)
+	emailOrder := make([]string, 0, len(emails))
+	emailRepr := make(map[string]string, len(emails))
+	emailFailed := make(map[string]bool, len(emails))
 	seenEmail := make(map[string]struct{}, len(emails))
 	for _, email := range emails {
 		if email == "" {
@@ -994,30 +1000,194 @@ func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string,
 			recordErr("%s: %v", email, err)
 			continue
 		}
-		intersection := make([]int, 0, len(currentIds))
+		matched := false
 		for _, id := range currentIds {
 			if _, ok := requested[id]; ok {
-				intersection = append(intersection, id)
+				recsByInbound[id] = append(recsByInbound[id], rec)
+				matched = true
 			}
 		}
-		if len(intersection) == 0 {
+		if !matched {
 			result.Skipped = append(result.Skipped, rec.Email)
 			continue
 		}
-		nr, err := s.Detach(inboundSvc, rec.Id, intersection)
+		emailOrder = append(emailOrder, key)
+		emailRepr[key] = rec.Email
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		recs, ok := recsByInbound[ibId]
+		if !ok {
+			continue
+		}
+		delete(recsByInbound, ibId)
+		nr, err := s.delInboundClients(inboundSvc, ibId, recs, true)
 		if err != nil {
-			recordErr("%s: %v", rec.Email, err)
+			recordErr("inbound %d: %v", ibId, err)
+			for _, rec := range recs {
+				emailFailed[strings.ToLower(rec.Email)] = true
+			}
 			continue
 		}
 		if nr {
 			needRestart = true
 		}
-		result.Detached = append(result.Detached, rec.Email)
+	}
+
+	for _, key := range emailOrder {
+		if emailFailed[key] {
+			continue
+		}
+		result.Detached = append(result.Detached, emailRepr[key])
 	}
 
 	return result, needRestart, nil
 }
 
+// delInboundClients removes several clients from a single inbound in one pass:
+// one settings rewrite, one runtime sweep, one Save and one SyncInbound for the
+// whole batch, instead of repeating the full per-client cycle. It mirrors the
+// semantics of DelInboundClient for each removed client. needRestart is the OR
+// across all removals.
+func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId int, recs []*model.ClientRecord, keepTraffic bool) (bool, error) {
+	if len(recs) == 0 {
+		return false, nil
+	}
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		logger.Error("Load Old Data Error")
+		return false, err
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
+		return false, err
+	}
+
+	clientKey := "id"
+	switch oldInbound.Protocol {
+	case "trojan":
+		clientKey = "password"
+	case "shadowsocks":
+		clientKey = "email"
+	case "hysteria":
+		clientKey = "auth"
+	}
+
+	wanted := make(map[string]struct{}, len(recs))
+	for _, rec := range recs {
+		if k := clientKeyForProtocol(oldInbound.Protocol, rec); k != "" {
+			wanted[k] = struct{}{}
+		}
+	}
+
+	interfaceClients, ok := settings["clients"].([]any)
+	if !ok {
+		return false, common.NewError("invalid clients format in inbound settings")
+	}
+
+	type removedClient struct {
+		email      string
+		needApiDel bool
+	}
+	removed := make([]removedClient, 0, len(wanted))
+	newClients := make([]any, 0, len(interfaceClients))
+	for _, client := range interfaceClients {
+		c, ok := client.(map[string]any)
+		if !ok {
+			newClients = append(newClients, client)
+			continue
+		}
+		cid, _ := c[clientKey].(string)
+		if _, hit := wanted[cid]; hit && cid != "" {
+			email, _ := c["email"].(string)
+			enable, _ := c["enable"].(bool)
+			removed = append(removed, removedClient{email: email, needApiDel: enable})
+			continue
+		}
+		newClients = append(newClients, client)
+	}
+
+	if len(removed) == 0 {
+		return false, nil
+	}
+
+	db := database.GetDB()
+	newClients = compactOrphans(db, newClients)
+	if newClients == nil {
+		newClients = []any{}
+	}
+	settings["clients"] = newClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	oldInbound.Settings = string(newSettings)
+
+	needRestart := false
+	for _, r := range removed {
+		email := r.email
+		emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+		if err != nil {
+			return needRestart, err
+		}
+		if !emailShared && !keepTraffic {
+			if err := inboundSvc.DelClientIPs(db, email); err != nil {
+				logger.Error("Error in delete client IPs")
+				return needRestart, err
+			}
+		}
+		if len(email) > 0 {
+			var enables []bool
+			if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error; err != nil {
+				logger.Error("Get stats error")
+				return needRestart, err
+			}
+			notDepleted := len(enables) > 0 && enables[0]
+			if !emailShared && !keepTraffic {
+				if err := inboundSvc.DelClientStat(db, email); err != nil {
+					logger.Error("Delete stats Data Error")
+					return needRestart, err
+				}
+			}
+			if r.needApiDel && notDepleted && oldInbound.NodeID == nil {
+				rt, rterr := inboundSvc.runtimeFor(oldInbound)
+				if rterr != nil {
+					needRestart = true
+				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 != nil {
+					if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
+						needRestart = true
+					}
+				}
+			}
+		}
+		if oldInbound.NodeID != nil && len(email) > 0 {
+			rt, rterr := inboundSvc.runtimeFor(oldInbound)
+			if rterr != nil {
+				return needRestart, rterr
+			}
+			if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+				return needRestart, err1
+			}
+		}
+	}
+
+	if err := db.Save(oldInbound).Error; err != nil {
+		return needRestart, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		return needRestart, gcErr
+	}
+	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
+		return needRestart, err
+	}
+	return needRestart, nil
+}
+
 func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
 	if email == "" {
 		return false, common.NewError("client email is required")
@@ -2884,10 +3054,13 @@ func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []
 	return needRestart, nil
 }
 
-func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
-	emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
-	if err != nil {
-		return "", err
+func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client, emailSubIDs map[string]string) (string, error) {
+	if emailSubIDs == nil {
+		var err error
+		emailSubIDs, err = inboundSvc.getAllEmailSubIDs()
+		if err != nil {
+			return "", err
+		}
 	}
 	seen := make(map[string]string, len(clients))
 	for _, client := range clients {
@@ -2912,6 +3085,14 @@ func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, c
 }
 
 func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
+	return s.addInboundClient(inboundSvc, data, nil)
+}
+
+// addInboundClient is AddInboundClient with an optional precomputed email→subId
+// map. Bulk callers pass a single snapshot so the global getAllEmailSubIDs scan
+// runs once for the whole batch instead of once per target inbound; a nil map
+// makes it compute its own (the single-add path).
+func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model.Inbound, emailSubIDs map[string]string) (bool, error) {
 	defer lockInbound(data.Id).Unlock()
 
 	clients, err := inboundSvc.GetClients(data)
@@ -2940,7 +3121,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			interfaceClients[i] = cm
 		}
 	}
-	existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
+	existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, emailSubIDs)
 	if err != nil {
 		return false, err
 	}
@@ -3159,7 +3340,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	}
 
 	if clients[0].Email != oldEmail {
-		existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
+		existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, nil)
 		if err != nil {
 			return false, err
 		}

+ 11 - 7
web/service/fallback.go

@@ -63,12 +63,16 @@ func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error
 			return err
 		}
 		for i, c := range items {
-			if c.ChildId <= 0 || c.ChildId == masterId {
+			childId := c.ChildId
+			if childId == masterId {
+				childId = 0
+			}
+			if childId <= 0 && strings.TrimSpace(c.Dest) == "" {
 				continue
 			}
 			row := model.InboundFallback{
 				MasterId:  masterId,
-				ChildId:   c.ChildId,
+				ChildId:   childId,
 				Name:      c.Name,
 				Alpn:      c.Alpn,
 				Path:      c.Path,
@@ -117,12 +121,12 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s
 
 	out := make([]map[string]any, 0, len(rows))
 	for _, r := range rows {
-		child, ok := byId[r.ChildId]
-		if !ok {
-			continue
-		}
-		dest := r.Dest
+		dest := strings.TrimSpace(r.Dest)
 		if dest == "" {
+			child, ok := byId[r.ChildId]
+			if !ok {
+				continue
+			}
 			listen := strings.TrimSpace(child.Listen)
 			if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
 				listen = "127.0.0.1"

+ 83 - 26
web/service/inbound.go

@@ -479,7 +479,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	if err != nil {
 		return inbound, false, err
 	}
-	existEmail, err := s.clientService.checkEmailsExistForClients(s, clients)
+	existEmail, err := s.clientService.checkEmailsExistForClients(s, clients, nil)
 	if err != nil {
 		return inbound, false, err
 	}
@@ -1251,6 +1251,18 @@ const resetGracePeriodMs int64 = 30000
 // long after a real disconnect.
 const onlineGracePeriodMs int64 = 20000
 
+type nodeTrafficCounter struct {
+	Up   int64
+	Down int64
+}
+
+func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email string, up, down int64) error {
+	return tx.Clauses(clause.OnConflict{
+		Columns:   []clause.Column{{Name: "node_id"}, {Name: "email"}},
+		DoUpdates: clause.AssignmentColumns([]string{"up", "down"}),
+	}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
+}
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
@@ -1313,6 +1325,26 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		centralCSByEmail[centralClientStats[i].Email] = &centralClientStats[i]
 	}
 
+	nodeBaselines := make(map[string]nodeTrafficCounter)
+	var baselineRows []model.NodeClientTraffic
+	if err := db.Model(&model.NodeClientTraffic{}).
+		Where("node_id = ?", nodeID).
+		Find(&baselineRows).Error; err != nil {
+		return false, err
+	}
+	for i := range baselineRows {
+		nodeBaselines[baselineRows[i].Email] = nodeTrafficCounter{Up: baselineRows[i].Up, Down: baselineRows[i].Down}
+	}
+
+	var existingEmailsList []string
+	if err := db.Model(xray.ClientTraffic{}).Pluck("email", &existingEmailsList).Error; err != nil {
+		return false, err
+	}
+	existingEmails := make(map[string]struct{}, len(existingEmailsList))
+	for _, e := range existingEmailsList {
+		existingEmails[e] = struct{}{}
+	}
+
 	var defaultUserId int
 	if len(central) > 0 {
 		defaultUserId = central[0].UserId
@@ -1458,6 +1490,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if _, kept := snapTags[c.Tag]; kept {
 			continue
 		}
+		var goneEmails []string
+		if err := tx.Model(xray.ClientTraffic{}).
+			Where("inbound_id = ?", c.Id).
+			Pluck("email", &goneEmails).Error; err != nil {
+			return false, err
+		}
+		if len(goneEmails) > 0 {
+			if err := tx.Where("node_id = ? AND email IN ?", nodeID, goneEmails).
+				Delete(&model.NodeClientTraffic{}).Error; err != nil {
+				return false, err
+			}
+		}
 		if err := tx.Where("inbound_id = ?", c.Id).
 			Delete(&xray.ClientTraffic{}).Error; err != nil {
 			return false, err
@@ -1481,17 +1525,22 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if !ok {
 			continue
 		}
-		inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
-
 		snapEmails := make(map[string]struct{}, len(snapIb.ClientStats))
 		for _, cs := range snapIb.ClientStats {
 			snapEmails[cs.Email] = struct{}{}
 
-			existing := centralCS[csKey{c.Id, cs.Email}]
-			if existing == nil {
-				existing = centralCSByEmail[cs.Email]
+			base, seen := nodeBaselines[cs.Email]
+			var deltaUp, deltaDown int64
+			if seen {
+				if deltaUp = cs.Up - base.Up; deltaUp < 0 {
+					deltaUp = cs.Up
+				}
+				if deltaDown = cs.Down - base.Down; deltaDown < 0 {
+					deltaDown = cs.Down
+				}
 			}
-			if existing == nil {
+
+			if _, rowExists := existingEmails[cs.Email]; !rowExists {
 				row := &xray.ClientTraffic{
 					InboundId:  c.Id,
 					Email:      cs.Email,
@@ -1509,42 +1558,40 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				}
 				centralCS[csKey{c.Id, cs.Email}] = row
 				centralCSByEmail[cs.Email] = row
+				existingEmails[cs.Email] = struct{}{}
 				structuralChange = true
+				if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
+					return false, err
+				}
+				nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
 				continue
 			}
 
-			if existing.Enable != cs.Enable ||
-				existing.Total != cs.Total ||
-				existing.ExpiryTime != cs.ExpiryTime ||
-				existing.Reset != cs.Reset {
+			if existing := centralCSByEmail[cs.Email]; existing != nil &&
+				(existing.Enable != cs.Enable ||
+					existing.Total != cs.Total ||
+					existing.ExpiryTime != cs.ExpiryTime ||
+					existing.Reset != cs.Reset) {
 				structuralChange = true
 			}
 
-			if inGrace && cs.Up+cs.Down > 0 {
-				if err := tx.Exec(
-					`UPDATE client_traffics
-					 SET enable = ?, total = ?, expiry_time = ?, reset = ?
-					 WHERE email = ?`,
-					cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, cs.Email,
-				).Error; err != nil {
-					return false, err
-				}
-				continue
-			}
-
 			if err := tx.Exec(
 				fmt.Sprintf(
 					`UPDATE client_traffics
-					 SET up = ?, down = ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
+					 SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
 					     last_online = %s
 					 WHERE email = ?`,
 					database.GreatestExpr("last_online", "?"),
 				),
-				cs.Up, cs.Down, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
+				deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
 				cs.LastOnline, cs.Email,
 			).Error; err != nil {
 				return false, err
 			}
+			if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
+				return false, err
+			}
+			nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
 		}
 
 		for k, existing := range centralCS {
@@ -1554,6 +1601,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if _, kept := snapEmails[k.email]; kept {
 				continue
 			}
+			if err := tx.Where("node_id = ? AND email = ?", nodeID, existing.Email).
+				Delete(&model.NodeClientTraffic{}).Error; err != nil {
+				return false, err
+			}
 			if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
 				Delete(&xray.ClientTraffic{}).Error; err != nil {
 				return false, err
@@ -1671,6 +1722,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 				logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
 			}
+			if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+				logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
+			}
 			structuralChange = true
 		}
 	}
@@ -2329,7 +2383,10 @@ func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail
 }
 
 func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
-	return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error
+	if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
+		return err
+	}
+	return tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error
 }
 
 func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {

+ 133 - 1
web/service/node.go

@@ -2,6 +2,11 @@ package service
 
 import (
 	"context"
+	"crypto/sha256"
+	"crypto/subtle"
+	"crypto/tls"
+	"encoding/base64"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -42,6 +47,113 @@ var nodeHTTPClient = &http.Client{
 	},
 }
 
+// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
+// per-node TLS verification mode. "verify" (or any http node) uses the shared
+// client with default certificate validation. "skip" disables validation.
+// "pin" disables the default chain check but verifies the leaf certificate's
+// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
+func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
+	mode := n.TlsVerifyMode
+	if mode == "" {
+		mode = "verify"
+	}
+	if mode == "verify" || n.Scheme == "http" {
+		return nodeHTTPClient, nil
+	}
+	tlsCfg := &tls.Config{InsecureSkipVerify: true}
+	if mode == "pin" {
+		want, err := decodeCertPin(n.PinnedCertSha256)
+		if err != nil {
+			return nil, err
+		}
+		tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
+			if len(cs.PeerCertificates) == 0 {
+				return common.NewError("node presented no certificate")
+			}
+			sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
+			if subtle.ConstantTimeCompare(sum[:], want) != 1 {
+				return common.NewError("node certificate does not match pinned SHA-256")
+			}
+			return nil
+		}
+	}
+	return &http.Client{
+		Transport: &http.Transport{
+			MaxIdleConns:        64,
+			MaxIdleConnsPerHost: 4,
+			IdleConnTimeout:     60 * time.Second,
+			DialContext:         netsafe.SSRFGuardedDialContext,
+			TLSClientConfig:     tlsCfg,
+		},
+	}, nil
+}
+
+// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
+// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
+// -fingerprint style) and returns the 32 raw bytes.
+func decodeCertPin(s string) ([]byte, error) {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return nil, common.NewError("certificate pin is empty")
+	}
+	if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
+		return b, nil
+	}
+	for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
+		if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
+			return b, nil
+		}
+	}
+	return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
+}
+
+// FetchCertFingerprint connects to the node over HTTPS without verifying the
+// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
+// can offer a "fetch and pin current certificate" action.
+func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
+	addr, err := netsafe.NormalizeHost(n.Address)
+	if err != nil {
+		return "", err
+	}
+	scheme := n.Scheme
+	if scheme != "http" && scheme != "https" {
+		scheme = "https"
+	}
+	if scheme != "https" {
+		return "", common.NewError("certificate pinning is only available for https nodes")
+	}
+	if n.Port <= 0 || n.Port > 65535 {
+		return "", common.NewError("node port must be 1-65535")
+	}
+	probeURL := &url.URL{
+		Scheme: scheme,
+		Host:   net.JoinHostPort(addr, strconv.Itoa(n.Port)),
+		Path:   normalizeBasePath(n.BasePath) + "panel/api/server/status",
+	}
+	req, err := http.NewRequestWithContext(
+		netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
+		http.MethodGet, probeURL.String(), nil)
+	if err != nil {
+		return "", err
+	}
+	client := &http.Client{
+		Transport: &http.Transport{
+			DialContext:     netsafe.SSRFGuardedDialContext,
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check]
+		},
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
+		return "", common.NewError("node did not present a TLS certificate")
+	}
+	sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
+	return base64.StdEncoding.EncodeToString(sum[:]), nil
+}
+
 func (s *NodeService) GetAll() ([]*model.Node, error) {
 	db := database.GetDB()
 	var nodes []*model.Node
@@ -187,6 +299,15 @@ func (s *NodeService) normalize(n *model.Node) error {
 	if n.Scheme != "http" && n.Scheme != "https" {
 		n.Scheme = "https"
 	}
+	if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
+		n.TlsVerifyMode = "verify"
+	}
+	n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
+	if n.TlsVerifyMode == "pin" {
+		if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
+			return common.NewError(err.Error())
+		}
+	}
 	n.BasePath = normalizeBasePath(n.BasePath)
 	return nil
 }
@@ -218,6 +339,8 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"api_token":             in.ApiToken,
 		"enable":                in.Enable,
 		"allow_private_address": in.AllowPrivateAddress,
+		"tls_verify_mode":       in.TlsVerifyMode,
+		"pinned_cert_sha256":    in.PinnedCertSha256,
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -233,6 +356,9 @@ func (s *NodeService) Delete(id int) error {
 	if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
 		return err
 	}
+	if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+		return err
+	}
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)
 	}
@@ -362,8 +488,14 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	req.Header.Set("Accept", "application/json")
 
+	client, err := nodeHTTPClientFor(n)
+	if err != nil {
+		patch.LastError = err.Error()
+		return patch, err
+	}
+
 	start := time.Now()
-	resp, err := nodeHTTPClient.Do(req)
+	resp, err := client.Do(req)
 	if err != nil {
 		patch.LastError = err.Error()
 		return patch, err

+ 209 - 0
web/service/node_client_traffic_sum_test.go

@@ -0,0 +1,209 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+	"gorm.io/gorm"
+)
+
+func initTrafficTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	return database.GetDB()
+}
+
+func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) {
+	t.Helper()
+	nid := nodeID
+	ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create node inbound %q: %v", tag, err)
+	}
+}
+
+func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) {
+	t.Helper()
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
+	}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+		t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
+	}
+}
+
+func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic {
+	t.Helper()
+	var ct xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil {
+		t.Fatalf("read client_traffics %q: %v", email, err)
+	}
+	return ct
+}
+
+func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) {
+	t.Helper()
+	if ct.Up != wantUp || ct.Down != wantDown {
+		t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown)
+	}
+}
+
+func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	createNodeInbound(t, db, 2, "n2-in", 41002)
+	svc := &InboundService{}
+
+	const email = "shared"
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
+
+	assertUpDown(t, readTraffic(t, db, email), 100, 100, "after baselines")
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
+
+	assertUpDown(t, readTraffic(t, db, email), 210, 210, "after both nodes grow")
+}
+
+func TestSingleNode_MirrorsCorrectly(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "solo"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 500, 600, "first sync")
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 700, 800, "second sync mirrors cumulative")
+}
+
+func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "legacy"
+	var ib model.Inbound
+	if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
+		t.Fatalf("load inbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil {
+		t.Fatalf("seed pre-existing row: %v", err)
+	}
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count")
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues")
+}
+
+func TestNodeCounterReset_Clamped(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "restart"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 950, 950, "before node reset")
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
+	ct := readTraffic(t, db, email)
+	if ct.Up < 0 || ct.Down < 0 {
+		t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
+	}
+	assertUpDown(t, ct, 1000, 1000, "after node counter reset (clamped)")
+}
+
+func TestCentralReset_NoReAdd(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	createNodeInbound(t, db, 2, "n2-in", 41002)
+	svc := &InboundService{}
+
+	const email = "reset"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
+
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
+		Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
+		t.Fatalf("simulate central reset: %v", err)
+	}
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true})
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true})
+
+	assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
+}
+
+func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	const email = "gone"
+	if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil {
+		t.Fatalf("seed node baseline 1: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil {
+		t.Fatalf("seed node baseline 2: %v", err)
+	}
+
+	if err := svc.DelClientStat(db, email); err != nil {
+		t.Fatalf("DelClientStat: %v", err)
+	}
+
+	var cnt int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if cnt != 0 {
+		t.Errorf("expected node baselines cleaned, found %d", cnt)
+	}
+}
+
+func TestNodeDelete_CleansNodeBaselines(t *testing.T) {
+	db := initTrafficTestDB(t)
+	nodeSvc := NodeService{}
+
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil {
+		t.Fatalf("seed node 7 a: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil {
+		t.Fatalf("seed node 7 b: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil {
+		t.Fatalf("seed node 8 c: %v", err)
+	}
+
+	if err := nodeSvc.Delete(7); err != nil {
+		t.Fatalf("NodeService.Delete(7): %v", err)
+	}
+
+	var sevenCnt, eightCnt int64
+	db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt)
+	db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt)
+	if sevenCnt != 0 {
+		t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt)
+	}
+	if eightCnt != 1 {
+		t.Errorf("node 8 baseline should survive, found %d", eightCnt)
+	}
+}

+ 5 - 5
web/service/sub_uri_base_test.go

@@ -26,11 +26,11 @@ func TestBuildSubURIBase(t *testing.T) {
 	}
 
 	cases := []struct {
-		name                    string
-		subDomain, port         string
-		cert, key               string
-		host                    string
-		want                    string
+		name            string
+		subDomain, port string
+		cert, key       string
+		host            string
+		want            string
 	}{
 		{"no domain, plain, non-standard port", "", "2096", "", "", "panel.example.com", "http://panel.example.com:2096"},
 		{"host carries a port — stripped, sub port applied", "", "2096", "", "", "panel.example.com:9999", "http://panel.example.com:2096"},

+ 5 - 0
web/service/warp.go

@@ -106,6 +106,11 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
 		"license_key":  license,
 		"private_key":  secretKey,
 	}
+	if config, ok := rsp["config"].(map[string]any); ok {
+		if clientID, ok := config["client_id"].(string); ok {
+			warpData["client_id"] = clientID
+		}
+	}
 	warpJSON, err := json.MarshalIndent(warpData, "", "  ")
 	if err != nil {
 		return "", err

+ 18 - 3
web/translation/ar-EG.json

@@ -264,7 +264,7 @@
       "localPanel": "بانل محلي",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى inbound آخر. اختر فرعًا أدناه وسيتم ملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقل الفرع — في الغالب لا تحتاج إلى أي تعديل إضافي. يجب أن يستمع كل فرع على 127.0.0.1 مع security=none.",
+        "help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى مكان آخر. اختر inbound فرعيًا أدناه لملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقله، أو اترك القائمة فارغة واضبط Dest مباشرةً (مثل 8080 أو 127.0.0.1:8080) للتوجيه إلى خادم خارجي مثل Nginx. يجب أن يستمع كل inbound فرعي على 127.0.0.1 مع security=none.",
         "empty": "لا توجد fallbacks بعد",
         "add": "إضافة fallback",
         "pickInbound": "اختر inbound",
@@ -570,7 +570,8 @@
         "getNewCert": "احصل على شهادة جديدة",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "احصل على Seed جديد"
+        "getNewSeed": "احصل على Seed جديد",
+        "listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0."
       },
       "info": {
         "mode": "الوضع",
@@ -868,7 +869,19 @@
         "updateStarted": "بدأ تحديث اللوحة",
         "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
         "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
-      }
+      },
+      "tlsVerifyMode": "التحقق من TLS",
+      "tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
+      "tlsVerify": "تحقّق (CA الافتراضية)",
+      "tlsPin": "تثبيت الشهادة (SHA-256)",
+      "tlsSkip": "تخطّي التحقق",
+      "tlsSkipWarning": "تخطّي التحقق يزيل الحماية من هجمات الوسيط — قد يُعترض رمز الـ API. يُفضَّل تثبيت الشهادة بدلاً من ذلك.",
+      "pinnedCert": "SHA-256 للشهادة المثبّتة",
+      "pinnedCertHint": "SHA-256 لشهادة العقدة بصيغة base64 أو hex. استخدم \"جلب\" لقراءتها من العقدة الآن.",
+      "pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
+      "fetchPin": "جلب",
+      "pinFetched": "تم جلب شهادة العقدة الحالية",
+      "pinFetchFailed": "تعذّر جلب الشهادة"
     },
     "settings": {
       "title": "إعدادات البانل",
@@ -1193,6 +1206,8 @@
         "tagRequired": "الوسم مطلوب",
         "tagPlaceholder": "وسم-فريد",
         "localIpPlaceholder": "IP محلي",
+        "dialerProxyPlaceholder": "اختر مخرجًا لتمرير الاتصال عبره",
+        "dialerProxyHint": "وجّه هذا المخرج عبر مخرج آخر (حسب الوسم) لبناء سلسلة بروكسي. اتركه فارغًا للاتصال المباشر.",
         "addressRequired": "العنوان مطلوب",
         "portRequired": "المنفذ مطلوب",
         "optional": "اختياري",

+ 18 - 3
web/translation/en-US.json

@@ -264,7 +264,7 @@
       "localPanel": "Local panel",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.",
+        "help": "When a connection on this inbound does not match any client, route it elsewhere. Pick a child inbound below to auto-fill the routing fields (SNI / ALPN / path / xver) from its transport, or leave the picker empty and set Dest directly (e.g. 8080 or 127.0.0.1:8080) to route to an external server such as Nginx. Each child inbound should listen on 127.0.0.1 with security=none.",
         "empty": "No fallbacks yet",
         "add": "Add fallback",
         "pickInbound": "Pick an inbound",
@@ -570,7 +570,8 @@
         "getNewCert": "Get New Cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Get New Seed"
+        "getNewSeed": "Get New Seed",
+        "listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case."
       },
       "info": {
         "mode": "Mode",
@@ -868,7 +869,19 @@
         "updateStarted": "Panel update started",
         "updateResult": "Update triggered on {ok} node(s), {failed} failed",
         "updateNoneEligible": "Select at least one online, enabled node"
-      }
+      },
+      "tlsVerifyMode": "TLS verification",
+      "tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
+      "tlsVerify": "Verify (default CA)",
+      "tlsPin": "Pin certificate (SHA-256)",
+      "tlsSkip": "Skip verification",
+      "tlsSkipWarning": "Skipping verification removes protection against man-in-the-middle attacks — the API token could be intercepted. Prefer pinning the certificate.",
+      "pinnedCert": "Pinned certificate SHA-256",
+      "pinnedCertHint": "Base64 or hex SHA-256 of the node's certificate. Use Fetch to read it from the node now.",
+      "pinnedCertPlaceholder": "base64 or hex SHA-256",
+      "fetchPin": "Fetch",
+      "pinFetched": "Fetched the node's current certificate",
+      "pinFetchFailed": "Could not fetch the certificate"
     },
     "settings": {
       "title": "Panel Settings",
@@ -1193,6 +1206,8 @@
         "tagRequired": "Tag is required",
         "tagPlaceholder": "unique-tag",
         "localIpPlaceholder": "local IP",
+        "dialerProxyPlaceholder": "Select an outbound to chain through",
+        "dialerProxyHint": "Dial this outbound through another outbound (by tag) to build a proxy chain. Leave empty to connect directly.",
         "addressRequired": "Address is required",
         "portRequired": "Port is required",
         "optional": "optional",

+ 18 - 3
web/translation/es-ES.json

@@ -264,7 +264,7 @@
       "localPanel": "Panel local",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro inbound. Elige un hijo abajo y los campos de enrutamiento (SNI / ALPN / Path / xver) se rellenan automáticamente desde su transporte; la mayoría de las configuraciones no necesitan más ajustes. Cada hijo debe escuchar en 127.0.0.1 con security=none.",
+        "help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro lugar. Elige un inbound hijo abajo para rellenar automáticamente los campos de enrutamiento (SNI / ALPN / Path / xver) desde su transporte, o deja el selector vacío y define Dest directamente (p. ej. 8080 o 127.0.0.1:8080) para redirigir a un servidor externo como Nginx. Cada inbound hijo debe escuchar en 127.0.0.1 con security=none.",
         "empty": "Aún no hay fallbacks",
         "add": "Añadir fallback",
         "pickInbound": "Selecciona un inbound",
@@ -570,7 +570,8 @@
         "getNewCert": "Obtener nuevo cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Obtener nuevo Seed"
+        "getNewSeed": "Obtener nuevo Seed",
+        "listenHelp": "También puedes introducir una ruta de socket Unix (p. ej. /run/xray/in.sock) para escuchar en un socket en lugar de un puerto TCP; en ese caso, establece el Puerto en 0."
       },
       "info": {
         "mode": "Modo",
@@ -868,7 +869,19 @@
         "updateStarted": "Actualización del panel iniciada",
         "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
         "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado"
-      }
+      },
+      "tlsVerifyMode": "Verificación TLS",
+      "tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).",
+      "tlsVerify": "Verificar (CA predeterminada)",
+      "tlsPin": "Fijar certificado (SHA-256)",
+      "tlsSkip": "Omitir verificación",
+      "tlsSkipWarning": "Omitir la verificación elimina la protección contra ataques de intermediario; el token de API podría ser interceptado. Es preferible fijar el certificado.",
+      "pinnedCert": "SHA-256 del certificado fijado",
+      "pinnedCertHint": "SHA-256 del certificado del nodo en base64 o hex. Usa Obtener para leerlo del nodo ahora.",
+      "pinnedCertPlaceholder": "SHA-256 en base64 o hex",
+      "fetchPin": "Obtener",
+      "pinFetched": "Se obtuvo el certificado actual del nodo",
+      "pinFetchFailed": "No se pudo obtener el certificado"
     },
     "settings": {
       "title": "Configuraciones",
@@ -1193,6 +1206,8 @@
         "tagRequired": "La etiqueta es obligatoria",
         "tagPlaceholder": "etiqueta-única",
         "localIpPlaceholder": "IP local",
+        "dialerProxyPlaceholder": "Selecciona una salida para encadenar",
+        "dialerProxyHint": "Conecta esta salida a través de otra salida (por etiqueta) para crear una cadena de proxy. Déjalo vacío para conectar directamente.",
         "addressRequired": "La dirección es obligatoria",
         "portRequired": "El puerto es obligatorio",
         "optional": "opcional",

+ 18 - 3
web/translation/fa-IR.json

@@ -264,7 +264,7 @@
       "localPanel": "پنل لوکال",
       "fallbacks": {
         "title": "Fallbackها",
-        "help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمی‌کند، به یک اینباند دیگر ارجاع داده می‌شود. یک فرزند انتخاب کنید، فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر می‌شود — برای بیشتر تنظیمات نیازی به ویرایش نیست. هر فرزند باید روی 127.0.0.1 با security=none گوش بدهد.",
+        "help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمی‌کند، به جای دیگری ارجاع داده می‌شود. یک اینباند فرزند از پایین انتخاب کنید تا فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر شود، یا انتخاب‌گر را خالی بگذارید و مقدار Dest را مستقیم تنظیم کنید (مثلاً 8080 یا 127.0.0.1:8080) تا به یک سرور بیرونی مانند Nginx ارجاع شود. هر اینباند فرزند باید روی 127.0.0.1 با security=none گوش بدهد.",
         "empty": "هنوز فال‌بکی اضافه نشده",
         "add": "افزودن فال‌بک",
         "pickInbound": "یک اینباند انتخاب کنید",
@@ -570,7 +570,8 @@
         "getNewCert": "دریافت گواهی جدید",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "دریافت Seed جدید"
+        "getNewSeed": "دریافت Seed جدید",
+        "listenHelp": "می‌توانید به‌جای پورت TCP یک مسیر سوکت یونیکس وارد کنید (مثلاً /run/xray/in.sock) تا روی سوکت گوش داده شود — در این حالت پورت را روی ۰ بگذارید."
       },
       "info": {
         "mode": "حالت",
@@ -868,7 +869,19 @@
         "updateStarted": "به‌روزرسانی پنل آغاز شد",
         "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
         "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید"
-      }
+      },
+      "tlsVerifyMode": "اعتبارسنجی TLS",
+      "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",
+      "tlsVerify": "اعتبارسنجی (CA پیش‌فرض)",
+      "tlsPin": "Pin گواهی (SHA-256)",
+      "tlsSkip": "رد کردن اعتبارسنجی",
+      "tlsSkipWarning": "رد کردن اعتبارسنجی محافظت در برابر حملهٔ مرد میانی را از بین می‌برد و توکن API ممکن است شنود شود. ترجیحاً به‌جای آن گواهی را Pin کنید.",
+      "pinnedCert": "SHA-256 گواهیِ Pin‌شده",
+      "pinnedCertHint": "SHA-256 گواهیِ نود به‌صورت base64 یا hex. برای خواندنِ همین حالا از نود، از دکمهٔ Fetch استفاده کنید.",
+      "pinnedCertPlaceholder": "SHA-256 به‌صورت base64 یا hex",
+      "fetchPin": "دریافت",
+      "pinFetched": "گواهیِ فعلیِ نود دریافت شد",
+      "pinFetchFailed": "دریافت گواهی ممکن نشد"
     },
     "settings": {
       "title": "تنظیمات پنل",
@@ -1193,6 +1206,8 @@
         "tagRequired": "تگ الزامی است",
         "tagPlaceholder": "تگ-منحصربه‌فرد",
         "localIpPlaceholder": "IP محلی",
+        "dialerProxyPlaceholder": "یک خروجی برای زنجیره کردن انتخاب کنید",
+        "dialerProxyHint": "این خروجی را از طریق خروجی دیگری (با تگ) برقرار کن تا یک زنجیره پروکسی ساخته شود. برای اتصال مستقیم خالی بگذار.",
         "addressRequired": "آدرس الزامی است",
         "portRequired": "پورت الزامی است",
         "optional": "اختیاری",

+ 18 - 3
web/translation/id-ID.json

@@ -264,7 +264,7 @@
       "localPanel": "Panel lokal",
       "fallbacks": {
         "title": "Fallback",
-        "help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke inbound lain. Pilih child di bawah dan field routing (SNI / ALPN / Path / xver) terisi otomatis dari transport-nya — sebagian besar konfigurasi tidak perlu disesuaikan lagi. Setiap child harus listen di 127.0.0.1 dengan security=none.",
+        "help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke tempat lain. Pilih inbound child di bawah untuk mengisi otomatis field routing (SNI / ALPN / Path / xver) dari transport-nya, atau biarkan pemilih kosong dan atur Dest langsung (mis. 8080 atau 127.0.0.1:8080) untuk mengarahkan ke server eksternal seperti Nginx. Setiap inbound child harus listen di 127.0.0.1 dengan security=none.",
         "empty": "Belum ada fallback",
         "add": "Tambah fallback",
         "pickInbound": "Pilih inbound",
@@ -570,7 +570,8 @@
         "getNewCert": "Dapatkan sertifikat baru",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Dapatkan Seed baru"
+        "getNewSeed": "Dapatkan Seed baru",
+        "listenHelp": "Anda juga dapat memasukkan path Unix socket (mis. /run/xray/in.sock) untuk listen pada socket alih-alih port TCP — dalam hal ini setel Port ke 0."
       },
       "info": {
         "mode": "Mode",
@@ -868,7 +869,19 @@
         "updateStarted": "Pembaruan panel dimulai",
         "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
         "updateNoneEligible": "Pilih minimal satu node online dan aktif"
-      }
+      },
+      "tlsVerifyMode": "Verifikasi TLS",
+      "tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).",
+      "tlsVerify": "Verifikasi (CA bawaan)",
+      "tlsPin": "Pin sertifikat (SHA-256)",
+      "tlsSkip": "Lewati verifikasi",
+      "tlsSkipWarning": "Melewati verifikasi menghilangkan perlindungan terhadap serangan man-in-the-middle — token API bisa disadap. Lebih baik pin sertifikat.",
+      "pinnedCert": "SHA-256 sertifikat yang dipin",
+      "pinnedCertHint": "SHA-256 sertifikat node dalam base64 atau hex. Gunakan Ambil untuk membacanya dari node sekarang.",
+      "pinnedCertPlaceholder": "SHA-256 base64 atau hex",
+      "fetchPin": "Ambil",
+      "pinFetched": "Berhasil mengambil sertifikat node saat ini",
+      "pinFetchFailed": "Tidak dapat mengambil sertifikat"
     },
     "settings": {
       "title": "Pengaturan Panel",
@@ -1193,6 +1206,8 @@
         "tagRequired": "Tag wajib diisi",
         "tagPlaceholder": "tag-unik",
         "localIpPlaceholder": "IP lokal",
+        "dialerProxyPlaceholder": "Pilih outbound untuk dirantai",
+        "dialerProxyHint": "Hubungkan outbound ini melalui outbound lain (berdasarkan tag) untuk membuat rantai proxy. Kosongkan untuk terhubung langsung.",
         "addressRequired": "Alamat wajib diisi",
         "portRequired": "Port wajib diisi",
         "optional": "opsional",

+ 18 - 3
web/translation/ja-JP.json

@@ -264,7 +264,7 @@
       "localPanel": "ローカルパネル",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別のインバウンドへルーティングします。下から子インバウンドを選ぶとルーティング項目(SNI / ALPN / Path / xver)はその子のトランスポートから自動的に埋められます — ほとんどの構成で追加の調整は不要です。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。",
+        "help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別の宛先へルーティングします。下から子インバウンドを選ぶとルーティング項目(SNI / ALPN / Path / xver)がそのトランスポートから自動的に埋められます。あるいは選択を空のままにして Dest を直接指定すると(例: 8080 または 127.0.0.1:8080)、Nginx などの外部サーバーへルーティングできます。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。",
         "empty": "フォールバックはまだありません",
         "add": "フォールバックを追加",
         "pickInbound": "インバウンドを選択",
@@ -570,7 +570,8 @@
         "getNewCert": "新しい証明書を取得",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "新しい Seed を取得"
+        "getNewSeed": "新しい Seed を取得",
+        "listenHelp": "TCP ポートの代わりに Unix ソケットのパス(例: /run/xray/in.sock)を入力してソケットでリッスンすることもできます。その場合はポートを 0 に設定してください。"
       },
       "info": {
         "mode": "モード",
@@ -868,7 +869,19 @@
         "updateStarted": "パネルの更新を開始しました",
         "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
         "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください"
-      }
+      },
+      "tlsVerifyMode": "TLS 検証",
+      "tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",
+      "tlsVerify": "検証(既定の CA)",
+      "tlsPin": "証明書をピン留め(SHA-256)",
+      "tlsSkip": "検証をスキップ",
+      "tlsSkipWarning": "検証をスキップすると中間者攻撃への保護がなくなり、API トークンが傍受される恐れがあります。証明書のピン留めを推奨します。",
+      "pinnedCert": "ピン留め証明書の SHA-256",
+      "pinnedCertHint": "ノード証明書の SHA-256(base64 または hex)。「取得」でノードから今すぐ読み取れます。",
+      "pinnedCertPlaceholder": "base64 または hex の SHA-256",
+      "fetchPin": "取得",
+      "pinFetched": "ノードの現在の証明書を取得しました",
+      "pinFetchFailed": "証明書を取得できませんでした"
     },
     "settings": {
       "title": "パネル設定",
@@ -1193,6 +1206,8 @@
         "tagRequired": "タグは必須です",
         "tagPlaceholder": "一意のタグ",
         "localIpPlaceholder": "ローカル IP",
+        "dialerProxyPlaceholder": "経由するアウトバウンドを選択",
+        "dialerProxyHint": "このアウトバウンドを別のアウトバウンド(タグ指定)経由で接続し、プロキシチェーンを構成します。直接接続する場合は空のままにします。",
         "addressRequired": "アドレスは必須です",
         "portRequired": "ポートは必須です",
         "optional": "任意",

+ 18 - 3
web/translation/pt-BR.json

@@ -264,7 +264,7 @@
       "localPanel": "Painel local",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro inbound. Escolha um filho abaixo e os campos de roteamento (SNI / ALPN / Path / xver) são preenchidos automaticamente a partir do transporte dele — a maioria das configurações não precisa de mais ajustes. Cada filho deve escutar em 127.0.0.1 com security=none.",
+        "help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro lugar. Escolha um inbound filho abaixo para preencher automaticamente os campos de roteamento (SNI / ALPN / Path / xver) a partir do transporte dele, ou deixe o seletor vazio e defina Dest diretamente (ex.: 8080 ou 127.0.0.1:8080) para rotear para um servidor externo como o Nginx. Cada inbound filho deve escutar em 127.0.0.1 com security=none.",
         "empty": "Ainda sem fallbacks",
         "add": "Adicionar fallback",
         "pickInbound": "Escolha um inbound",
@@ -570,7 +570,8 @@
         "getNewCert": "Obter novo certificado",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Obter novo Seed"
+        "getNewSeed": "Obter novo Seed",
+        "listenHelp": "Você também pode informar um caminho de socket Unix (ex.: /run/xray/in.sock) para escutar em um socket em vez de uma porta TCP — nesse caso, defina a Porta como 0."
       },
       "info": {
         "mode": "Modo",
@@ -868,7 +869,19 @@
         "updateStarted": "Atualização do painel iniciada",
         "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
         "updateNoneEligible": "Selecione pelo menos um nó online e ativo"
-      }
+      },
+      "tlsVerifyMode": "Verificação TLS",
+      "tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).",
+      "tlsVerify": "Verificar (CA padrão)",
+      "tlsPin": "Fixar certificado (SHA-256)",
+      "tlsSkip": "Ignorar verificação",
+      "tlsSkipWarning": "Ignorar a verificação remove a proteção contra ataques man-in-the-middle — o token de API pode ser interceptado. Prefira fixar o certificado.",
+      "pinnedCert": "SHA-256 do certificado fixado",
+      "pinnedCertHint": "SHA-256 do certificado do nó em base64 ou hex. Use Obter para lê-lo do nó agora.",
+      "pinnedCertPlaceholder": "SHA-256 em base64 ou hex",
+      "fetchPin": "Obter",
+      "pinFetched": "Certificado atual do nó obtido",
+      "pinFetchFailed": "Não foi possível obter o certificado"
     },
     "settings": {
       "title": "Configurações do Painel",
@@ -1193,6 +1206,8 @@
         "tagRequired": "A tag é obrigatória",
         "tagPlaceholder": "tag-única",
         "localIpPlaceholder": "IP local",
+        "dialerProxyPlaceholder": "Selecione uma saída para encadear",
+        "dialerProxyHint": "Conecte esta saída através de outra saída (por tag) para criar uma cadeia de proxy. Deixe vazio para conectar diretamente.",
         "addressRequired": "Endereço é obrigatório",
         "portRequired": "Porta é obrigatória",
         "optional": "opcional",

+ 18 - 3
web/translation/ru-RU.json

@@ -264,7 +264,7 @@
       "localPanel": "Локальная панель",
       "fallbacks": {
         "title": "Fallback'и",
-        "help": "Когда соединение на этом инбаунде не совпадает ни с одним клиентом, оно перенаправляется на другой инбаунд. Выберите дочерний инбаунд ниже — поля маршрутизации (SNI / ALPN / Path / xver) заполнятся автоматически из его транспорта, для большинства конфигураций больше ничего менять не нужно. Каждый дочерний должен слушать на 127.0.0.1 с security=none.",
+        "help": "Когда соединение на этом инбаунде не совпадает ни с одним клиентом, оно перенаправляется в другое место. Выберите дочерний инбаунд ниже, чтобы поля маршрутизации (SNI / ALPN / Path / xver) заполнились автоматически из его транспорта, либо оставьте выбор пустым и задайте Dest напрямую (например, 8080 или 127.0.0.1:8080), чтобы перенаправить на внешний сервер, такой как Nginx. Каждый дочерний инбаунд должен слушать на 127.0.0.1 с security=none.",
         "empty": "Фолбэков пока нет",
         "add": "Добавить фолбэк",
         "pickInbound": "Выберите инбаунд",
@@ -570,7 +570,8 @@
         "getNewCert": "Получить новый сертификат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Получить новый Seed"
+        "getNewSeed": "Получить новый Seed",
+        "listenHelp": "Можно также указать путь Unix-сокета (например, /run/xray/in.sock), чтобы слушать сокет вместо TCP-порта — в этом случае задайте порт 0."
       },
       "info": {
         "mode": "Режим",
@@ -868,7 +869,19 @@
         "updateStarted": "Обновление панели запущено",
         "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
         "updateNoneEligible": "Выберите хотя бы один включённый узел в сети"
-      }
+      },
+      "tlsVerifyMode": "Проверка TLS",
+      "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",
+      "tlsVerify": "Проверять (стандартный CA)",
+      "tlsPin": "Закрепить сертификат (SHA-256)",
+      "tlsSkip": "Пропустить проверку",
+      "tlsSkipWarning": "Пропуск проверки убирает защиту от атак «человек посередине» — токен API может быть перехвачен. Лучше закрепить сертификат.",
+      "pinnedCert": "SHA-256 закреплённого сертификата",
+      "pinnedCertHint": "SHA-256 сертификата узла в base64 или hex. Нажмите «Получить», чтобы считать его с узла сейчас.",
+      "pinnedCertPlaceholder": "SHA-256 в base64 или hex",
+      "fetchPin": "Получить",
+      "pinFetched": "Текущий сертификат узла получен",
+      "pinFetchFailed": "Не удалось получить сертификат"
     },
     "settings": {
       "title": "Настройки",
@@ -1193,6 +1206,8 @@
         "tagRequired": "Тег обязателен",
         "tagPlaceholder": "уникальный-тег",
         "localIpPlaceholder": "локальный IP",
+        "dialerProxyPlaceholder": "Выберите исходящее для цепочки",
+        "dialerProxyHint": "Подключайте это исходящее через другое исходящее (по тегу), чтобы построить цепочку прокси. Оставьте пустым для прямого подключения.",
         "addressRequired": "Адрес обязателен",
         "portRequired": "Порт обязателен",
         "optional": "опционально",

+ 18 - 3
web/translation/tr-TR.json

@@ -264,7 +264,7 @@
       "localPanel": "Yerel panel",
       "fallbacks": {
         "title": "Fallback'ler",
-        "help": "Bu inbound üzerindeki bir bağlantı hiçbir client ile eşleşmediğinde, başka bir inbound'a yönlendirilir. Aşağıdan bir child seçin; yönlendirme alanları (SNI / ALPN / Path / xver) onun transport'undan otomatik dolar — çoğu kurulum için ek ayar gerekmez. Her child 127.0.0.1 üzerinde security=none ile dinlemelidir.",
+        "help": "Bu inbound üzerindeki bir bağlantı hiçbir client ile eşleşmediğinde, başka bir yere yönlendirilir. Aşağıdan bir child inbound seçerek yönlendirme alanlarını (SNI / ALPN / Path / xver) transport'undan otomatik doldurun ya da seçimi boş bırakıp Dest değerini doğrudan girin (örn. 8080 veya 127.0.0.1:8080); böylece Nginx gibi harici bir sunucuya yönlendirebilirsiniz. Her child inbound 127.0.0.1 üzerinde security=none ile dinlemelidir.",
         "empty": "Henüz fallback yok",
         "add": "Fallback ekle",
         "pickInbound": "Bir inbound seç",
@@ -570,7 +570,8 @@
         "getNewCert": "Yeni sertifika al",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Yeni Seed al"
+        "getNewSeed": "Yeni Seed al",
+        "listenHelp": "TCP portu yerine bir Unix soket yolu da girebilirsiniz (örn. /run/xray/in.sock) — bu durumda Portu 0 olarak ayarlayın."
       },
       "info": {
         "mode": "Mod",
@@ -868,7 +869,19 @@
         "updateStarted": "Panel güncellemesi başlatıldı",
         "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
         "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin"
-      }
+      },
+      "tlsVerifyMode": "TLS doğrulaması",
+      "tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağı. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).",
+      "tlsVerify": "Doğrula (varsayılan CA)",
+      "tlsPin": "Sertifikayı sabitle (SHA-256)",
+      "tlsSkip": "Doğrulamayı atla",
+      "tlsSkipWarning": "Doğrulamayı atlamak, ortadaki adam saldırılarına karşı korumayı kaldırır — API anahtarı ele geçirilebilir. Bunun yerine sertifikayı sabitlemeniz önerilir.",
+      "pinnedCert": "Sabitlenen sertifika SHA-256",
+      "pinnedCertHint": "Düğüm sertifikasının base64 veya hex biçiminde SHA-256 değeri. Şimdi düğümden okumak için Getir'i kullanın.",
+      "pinnedCertPlaceholder": "base64 veya hex SHA-256",
+      "fetchPin": "Getir",
+      "pinFetched": "Düğümün geçerli sertifikası alındı",
+      "pinFetchFailed": "Sertifika alınamadı"
     },
     "settings": {
       "title": "Panel Ayarları",
@@ -1193,6 +1206,8 @@
         "tagRequired": "Etiket gereklidir",
         "tagPlaceholder": "benzersiz-etiket",
         "localIpPlaceholder": "yerel IP",
+        "dialerProxyPlaceholder": "Zincirlemek için bir giden seçin",
+        "dialerProxyHint": "Bir proxy zinciri oluşturmak için bu gideni başka bir giden üzerinden (etikete göre) bağlayın. Doğrudan bağlanmak için boş bırakın.",
         "addressRequired": "Adres gereklidir",
         "portRequired": "Port gereklidir",
         "optional": "opsiyonel",

+ 18 - 3
web/translation/uk-UA.json

@@ -264,7 +264,7 @@
       "localPanel": "Локальна панель",
       "fallbacks": {
         "title": "Fallback'и",
-        "help": "Коли з'єднання на цьому інбаунді не збігається з жодним клієнтом, воно перенаправляється на інший інбаунд. Оберіть дочірній інбаунд нижче — поля маршрутизації (SNI / ALPN / Path / xver) заповняться автоматично з його транспорту; для більшості налаштувань більше нічого змінювати не треба. Кожен дочірній має слухати на 127.0.0.1 з security=none.",
+        "help": "Коли з'єднання на цьому інбаунді не збігається з жодним клієнтом, воно перенаправляється в інше місце. Оберіть дочірній інбаунд нижче, щоб поля маршрутизації (SNI / ALPN / Path / xver) заповнилися автоматично з його транспорту, або залиште вибір порожнім і задайте Dest напряму (наприклад, 8080 або 127.0.0.1:8080), щоб перенаправити на зовнішній сервер, такий як Nginx. Кожен дочірній інбаунд має слухати на 127.0.0.1 з security=none.",
         "empty": "Фолбеків поки немає",
         "add": "Додати фолбек",
         "pickInbound": "Оберіть інбаунд",
@@ -570,7 +570,8 @@
         "getNewCert": "Отримати новий сертифікат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Отримати новий Seed"
+        "getNewSeed": "Отримати новий Seed",
+        "listenHelp": "Можна також указати шлях Unix-сокета (наприклад, /run/xray/in.sock), щоб слухати сокет замість TCP-порту — у цьому разі встановіть порт 0."
       },
       "info": {
         "mode": "Режим",
@@ -868,7 +869,19 @@
         "updateStarted": "Оновлення панелі розпочато",
         "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
         "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі"
-      }
+      },
+      "tlsVerifyMode": "Перевірка TLS",
+      "tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",
+      "tlsVerify": "Перевіряти (стандартний CA)",
+      "tlsPin": "Закріпити сертифікат (SHA-256)",
+      "tlsSkip": "Пропустити перевірку",
+      "tlsSkipWarning": "Пропуск перевірки прибирає захист від атак «людина посередині» — токен API можуть перехопити. Краще закріпити сертифікат.",
+      "pinnedCert": "SHA-256 закріпленого сертифіката",
+      "pinnedCertHint": "SHA-256 сертифіката вузла у base64 або hex. Натисніть «Отримати», щоб зчитати його з вузла зараз.",
+      "pinnedCertPlaceholder": "SHA-256 у base64 або hex",
+      "fetchPin": "Отримати",
+      "pinFetched": "Поточний сертифікат вузла отримано",
+      "pinFetchFailed": "Не вдалося отримати сертифікат"
     },
     "settings": {
       "title": "Параметри панелі",
@@ -1193,6 +1206,8 @@
         "tagRequired": "Тег обов'язковий",
         "tagPlaceholder": "унікальний-тег",
         "localIpPlaceholder": "локальний IP",
+        "dialerProxyPlaceholder": "Виберіть вихідний для ланцюжка",
+        "dialerProxyHint": "Підключайте цей вихідний через інший вихідний (за тегом), щоб побудувати ланцюжок проксі. Залиште порожнім для прямого підключення.",
         "addressRequired": "Адреса обов'язкова",
         "portRequired": "Порт обов'язковий",
         "optional": "опційно",

+ 18 - 3
web/translation/vi-VN.json

@@ -264,7 +264,7 @@
       "localPanel": "Panel cục bộ",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "Khi một kết nối trên inbound này không khớp với client nào, nó sẽ được chuyển hướng tới inbound khác. Chọn một child bên dưới và các trường định tuyến (SNI / ALPN / Path / xver) sẽ được tự động điền từ transport của child — hầu hết cấu hình không cần chỉnh thêm. Mỗi child nên lắng nghe trên 127.0.0.1 với security=none.",
+        "help": "Khi một kết nối trên inbound này không khớp với client nào, hãy chuyển hướng nó tới nơi khác. Chọn một inbound con bên dưới để tự động điền các trường định tuyến (SNI / ALPN / Path / xver) từ transport của nó, hoặc để trống ô chọn và đặt Dest trực tiếp (ví dụ 8080 hoặc 127.0.0.1:8080) để chuyển hướng tới một máy chủ bên ngoài như Nginx. Mỗi inbound con nên lắng nghe trên 127.0.0.1 với security=none.",
         "empty": "Chưa có fallback nào",
         "add": "Thêm fallback",
         "pickInbound": "Chọn một inbound",
@@ -570,7 +570,8 @@
         "getNewCert": "Lấy chứng chỉ mới",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "Lấy Seed mới"
+        "getNewSeed": "Lấy Seed mới",
+        "listenHelp": "Bạn cũng có thể nhập đường dẫn Unix socket (ví dụ /run/xray/in.sock) để lắng nghe trên socket thay vì cổng TCP — khi đó hãy đặt Port là 0."
       },
       "info": {
         "mode": "Chế độ",
@@ -868,7 +869,19 @@
         "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
         "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
         "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật"
-      }
+      },
+      "tlsVerifyMode": "Xác minh TLS",
+      "tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).",
+      "tlsVerify": "Xác minh (CA mặc định)",
+      "tlsPin": "Ghim chứng chỉ (SHA-256)",
+      "tlsSkip": "Bỏ qua xác minh",
+      "tlsSkipWarning": "Bỏ qua xác minh sẽ loại bỏ bảo vệ trước tấn công xen giữa — token API có thể bị chặn bắt. Nên ghim chứng chỉ thay vì vậy.",
+      "pinnedCert": "SHA-256 của chứng chỉ đã ghim",
+      "pinnedCertHint": "SHA-256 của chứng chỉ node ở dạng base64 hoặc hex. Dùng Lấy để đọc trực tiếp từ node.",
+      "pinnedCertPlaceholder": "SHA-256 base64 hoặc hex",
+      "fetchPin": "Lấy",
+      "pinFetched": "Đã lấy chứng chỉ hiện tại của node",
+      "pinFetchFailed": "Không thể lấy chứng chỉ"
     },
     "settings": {
       "title": "Cài đặt",
@@ -1193,6 +1206,8 @@
         "tagRequired": "Tag là bắt buộc",
         "tagPlaceholder": "tag-duy-nhất",
         "localIpPlaceholder": "IP nội bộ",
+        "dialerProxyPlaceholder": "Chọn một outbound để nối chuỗi",
+        "dialerProxyHint": "Kết nối outbound này qua một outbound khác (theo tag) để tạo chuỗi proxy. Để trống để kết nối trực tiếp.",
         "addressRequired": "Địa chỉ là bắt buộc",
         "portRequired": "Cổng là bắt buộc",
         "optional": "tùy chọn",

+ 18 - 3
web/translation/zh-CN.json

@@ -264,7 +264,7 @@
       "localPanel": "本地面板",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "当此入站的连接未匹配任何客户端时,将其路由到另一个入站。在下方选择一个子入站,路由字段(SNI / ALPN / Path / xver)会从子入站的传输方式中自动填充——大多数场景无需再调整。每个子入站应监听 127.0.0.1,security=none。",
+        "help": "当此入站的连接未匹配任何客户端时,将其路由到其他位置。在下方选择一个子入站,可从其传输方式自动填充路由字段(SNI / ALPN / Path / xver);或将选择框留空并直接设置 Dest(例如 8080 或 127.0.0.1:8080),以路由到 Nginx 等外部服务器。每个子入站应监听 127.0.0.1,security=none。",
         "empty": "暂无回落",
         "add": "添加回落",
         "pickInbound": "选择一个入站",
@@ -570,7 +570,8 @@
         "getNewCert": "获取新证书",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "获取新 Seed"
+        "getNewSeed": "获取新 Seed",
+        "listenHelp": "也可以填写 Unix socket 路径(例如 /run/xray/in.sock),以使用套接字而非 TCP 端口监听——此时请将端口设为 0。"
       },
       "info": {
         "mode": "模式",
@@ -868,7 +869,19 @@
         "updateStarted": "已开始更新面板",
         "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
         "updateNoneEligible": "请至少选择一个在线且已启用的节点"
-      }
+      },
+      "tlsVerifyMode": "TLS 校验",
+      "tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",
+      "tlsVerify": "校验(默认 CA)",
+      "tlsPin": "固定证书(SHA-256)",
+      "tlsSkip": "跳过校验",
+      "tlsSkipWarning": "跳过校验会失去对中间人攻击的防护,API 令牌可能被截获。建议改用固定证书。",
+      "pinnedCert": "固定证书的 SHA-256",
+      "pinnedCertHint": "节点证书的 SHA-256(base64 或 hex)。点击“获取”可立即从节点读取。",
+      "pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
+      "fetchPin": "获取",
+      "pinFetched": "已获取节点当前证书",
+      "pinFetchFailed": "无法获取证书"
     },
     "settings": {
       "title": "面板设置",
@@ -1193,6 +1206,8 @@
         "tagRequired": "标签为必填项",
         "tagPlaceholder": "唯一标签",
         "localIpPlaceholder": "本地 IP",
+        "dialerProxyPlaceholder": "选择要串联的出站",
+        "dialerProxyHint": "让此出站通过另一个出站(按标签)拨号,以建立代理链。留空则直接连接。",
         "addressRequired": "地址为必填项",
         "portRequired": "端口为必填项",
         "optional": "可选",

+ 18 - 3
web/translation/zh-TW.json

@@ -264,7 +264,7 @@
       "localPanel": "本機面板",
       "fallbacks": {
         "title": "Fallbacks",
-        "help": "當此入站的連線未匹配任何用戶時,將其路由到另一個入站。在下方選擇一個子入站,路由欄位(SNI / ALPN / Path / xver)會自動從子入站的傳輸方式填入——大多數情境不需要再調整。每個子入站應監聽 127.0.0.1,security=none。",
+        "help": "當此入站的連線未匹配任何用戶時,將其路由到其他位置。在下方選擇一個子入站,可從其傳輸方式自動填入路由欄位(SNI / ALPN / Path / xver);或將選擇框留空並直接設定 Dest(例如 8080 或 127.0.0.1:8080),以路由到 Nginx 等外部伺服器。每個子入站應監聽 127.0.0.1,security=none。",
         "empty": "尚未新增回落",
         "add": "新增回落",
         "pickInbound": "選擇一個入站",
@@ -570,7 +570,8 @@
         "getNewCert": "取得新憑證",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
-        "getNewSeed": "取得新 Seed"
+        "getNewSeed": "取得新 Seed",
+        "listenHelp": "也可以填寫 Unix socket 路徑(例如 /run/xray/in.sock),以使用通訊端而非 TCP 連接埠監聽——此時請將連接埠設為 0。"
       },
       "info": {
         "mode": "模式",
@@ -868,7 +869,19 @@
         "updateStarted": "已開始更新面板",
         "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
         "updateNoneEligible": "請至少選擇一個在線且已啟用的節點"
-      }
+      },
+      "tlsVerifyMode": "TLS 驗證",
+      "tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",
+      "tlsVerify": "驗證(預設 CA)",
+      "tlsPin": "釘選憑證(SHA-256)",
+      "tlsSkip": "略過驗證",
+      "tlsSkipWarning": "略過驗證會失去對中間人攻擊的防護,API 權杖可能被攔截。建議改用釘選憑證。",
+      "pinnedCert": "釘選憑證的 SHA-256",
+      "pinnedCertHint": "節點憑證的 SHA-256(base64 或 hex)。點選「取得」可立即從節點讀取。",
+      "pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
+      "fetchPin": "取得",
+      "pinFetched": "已取得節點目前憑證",
+      "pinFetchFailed": "無法取得憑證"
     },
     "settings": {
       "title": "面板設定",
@@ -1193,6 +1206,8 @@
         "tagRequired": "標籤為必填",
         "tagPlaceholder": "唯一標籤",
         "localIpPlaceholder": "本地 IP",
+        "dialerProxyPlaceholder": "選擇要串接的出站",
+        "dialerProxyHint": "讓此出站透過另一個出站(以標籤指定)連線,以建立代理鏈。留空則直接連線。",
         "addressRequired": "地址為必填",
         "portRequired": "連接埠為必填",
         "optional": "選用",

+ 436 - 22
x-ui.sh

@@ -244,9 +244,9 @@ reset_user() {
 
     read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm
     if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then
-        ${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor false > /dev/null 2>&1
+        ${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" > /dev/null 2>&1
     else
-        ${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor true > /dev/null 2>&1
+        ${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor=true > /dev/null 2>&1
         echo -e "Two factor authentication has been disabled."
     fi
 
@@ -1600,11 +1600,10 @@ ssl_cert_issue_CF() {
     local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
     LOGI "****** Instructions for Use ******"
     LOGI "Follow the steps below to complete the process:"
-    LOGI "1. Cloudflare Registered E-mail."
-    LOGI "2. Cloudflare Global API Key."
-    LOGI "3. The Domain Name."
-    LOGI "4. Once the certificate is issued, you will be prompted to set the certificate for the panel (optional)."
-    LOGI "5. The script also supports automatic renewal of the SSL certificate after installation."
+    LOGI "1. A Cloudflare API Token (recommended, scoped to Zone:DNS:Edit) or the Global API Key + registered email."
+    LOGI "2. The Domain Name."
+    LOGI "3. Once the certificate is issued, you will be prompted to set the certificate for the panel (optional)."
+    LOGI "4. The script also supports automatic renewal of the SSL certificate after installation."
 
     confirm "Do you confirm the information and wish to proceed? [y/n]" "y"
 
@@ -1625,16 +1624,28 @@ ssl_cert_issue_CF() {
         read -rp "Input your domain here: " CF_Domain
         LOGD "Your domain name is set to: ${CF_Domain}"
 
-        # Set up Cloudflare API details
-        CF_GlobalKey=""
-        CF_AccountEmail=""
-        LOGD "Please set the API key:"
-        read -rp "Input your key here: " CF_GlobalKey
-        LOGD "Your API key is: ${CF_GlobalKey}"
-
-        LOGD "Please set up registered email:"
-        read -rp "Input your email here: " CF_AccountEmail
-        LOGD "Your registered email address is: ${CF_AccountEmail}"
+        # Cloudflare API credentials: an API Token (recommended, scoped to a
+        # single zone) or the account-wide Global API Key. acme.sh reads
+        # CF_Token for tokens, or CF_Key + CF_Email for the Global Key.
+        CF_KeyType=""
+        read -rp "Are you using a Cloudflare API Token or Global API Key? (t/g) [Default t]: " CF_KeyType
+        CF_KeyType=${CF_KeyType:-t}
+
+        if [[ "$CF_KeyType" == "g" || "$CF_KeyType" == "G" ]]; then
+            CF_GlobalKey=""
+            CF_AccountEmail=""
+            LOGD "Please set the Global API Key:"
+            read -rp "Input your key here: " CF_GlobalKey
+            LOGD "Please set up the registered email:"
+            read -rp "Input your email here: " CF_AccountEmail
+            export CF_Key="${CF_GlobalKey}"
+            export CF_Email="${CF_AccountEmail}"
+        else
+            CF_ApiToken=""
+            LOGD "Please set the API Token:"
+            read -rp "Input your token here: " CF_ApiToken
+            export CF_Token="${CF_ApiToken}"
+        fi
 
         # Set the default CA to Let's Encrypt
         ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
@@ -1643,9 +1654,6 @@ ssl_cert_issue_CF() {
             exit 1
         fi
 
-        export CF_Key="${CF_GlobalKey}"
-        export CF_Email="${CF_AccountEmail}"
-
         # Issue the certificate using Cloudflare DNS
         ~/.acme.sh/acme.sh --issue --dns dns_cf -d ${CF_Domain} -d *.${CF_Domain} --log --force
         if [ $? -ne 0 ]; then
@@ -2282,6 +2290,407 @@ SSH_port_forwarding() {
     esac
 }
 
+# PostgreSQL service management (for panels configured with XUI_DB_TYPE=postgres).
+
+postgresql_installed() {
+    command -v pg_lsclusters > /dev/null 2>&1 || command -v psql > /dev/null 2>&1 || command -v postgres > /dev/null 2>&1
+}
+
+# Prints "VER CLUSTER" of the first configured cluster on Debian-style installs (e.g. "16 main").
+pg_cluster_info() {
+    if command -v pg_lsclusters > /dev/null 2>&1; then
+        pg_lsclusters 2> /dev/null | awk '$1 ~ /^[0-9]+$/ {print $1, $2; exit}'
+    fi
+}
+
+# Resolves the systemd unit used to manage the PostgreSQL server.
+pg_systemd_unit() {
+    local info ver cluster
+    info="$(pg_cluster_info)"
+    if [[ -n "$info" ]]; then
+        ver="${info%% *}"
+        cluster="${info##* }"
+        echo "postgresql@${ver}-${cluster}"
+    else
+        echo "postgresql"
+    fi
+}
+
+postgresql_status() {
+    if ! postgresql_installed; then
+        LOGE "PostgreSQL does not appear to be installed on this system."
+        return 1
+    fi
+    if command -v pg_lsclusters > /dev/null 2>&1; then
+        pg_lsclusters
+    else
+        systemctl status "$(pg_systemd_unit)" --no-pager
+    fi
+    echo ""
+    if command -v ss > /dev/null 2>&1; then
+        local listening
+        listening=$(ss -ltnp 2> /dev/null | grep ':5432')
+        if [[ -n "$listening" ]]; then
+            echo -e "${green}PostgreSQL is listening on port 5432:${plain}"
+            echo "$listening"
+        else
+            echo -e "${red}Nothing is listening on port 5432 - the database is not running.${plain}"
+        fi
+    fi
+}
+
+postgresql_start() {
+    pg_require_installed || return 1
+    if [[ $release == "alpine" ]]; then
+        rc-service postgresql start
+    else
+        systemctl start "$(pg_systemd_unit)"
+    fi
+    sleep 1
+    postgresql_status
+}
+
+postgresql_stop() {
+    pg_require_installed || return 1
+    if [[ $release == "alpine" ]]; then
+        rc-service postgresql stop
+    else
+        systemctl stop "$(pg_systemd_unit)"
+    fi
+    LOGI "PostgreSQL stop signal sent."
+}
+
+postgresql_restart() {
+    pg_require_installed || return 1
+    if [[ $release == "alpine" ]]; then
+        rc-service postgresql restart
+    else
+        systemctl restart "$(pg_systemd_unit)"
+    fi
+    sleep 1
+    postgresql_status
+}
+
+postgresql_enable() {
+    pg_require_installed || return 1
+    if [[ $release == "alpine" ]]; then
+        rc-update add postgresql default
+    else
+        systemctl enable "$(pg_systemd_unit)"
+    fi
+    if [[ $? == 0 ]]; then
+        LOGI "PostgreSQL set to start automatically on boot."
+    else
+        LOGE "Failed to enable PostgreSQL autostart."
+    fi
+}
+
+postgresql_log() {
+    pg_require_installed || return 1
+    local info ver cluster logfile
+    info="$(pg_cluster_info)"
+    if [[ -n "$info" ]]; then
+        ver="${info%% *}"
+        cluster="${info##* }"
+        logfile="/var/log/postgresql/postgresql-${ver}-${cluster}.log"
+    fi
+    if [[ -n "$logfile" && -f "$logfile" ]]; then
+        tail -n 40 "$logfile"
+    elif command -v journalctl > /dev/null 2>&1; then
+        journalctl -u "$(pg_systemd_unit)" -n 40 --no-pager
+    else
+        LOGE "No PostgreSQL log found."
+    fi
+}
+
+pg_require_installed() {
+    if ! postgresql_installed; then
+        LOGE "PostgreSQL is not installed. Use option 1 (Install PostgreSQL) in this menu first."
+        return 1
+    fi
+}
+
+# Installs a local PostgreSQL server and creates a dedicated xui user/database.
+# Progress goes to stderr; on success the connection DSN is printed to stdout so
+# callers can capture it. Mirrors install_postgres_local() from install.sh, so the
+# panel can be set up without re-running the remote install script.
+pg_install_local() {
+    local pg_user pg_pass pg_db pg_host pg_port
+    pg_pass=$(gen_random_string 24)
+    pg_db="xui"
+    pg_host="127.0.0.1"
+    pg_port="5432"
+
+    case "${release}" in
+        ubuntu | debian | armbian)
+            apt-get update >&2 && apt-get install -y -q postgresql >&2 || return 1
+            ;;
+        fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
+            dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
+            [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
+            ;;
+        centos)
+            if [[ "${VERSION_ID}" =~ ^7 ]]; then
+                yum install -y postgresql-server postgresql-contrib >&2 || return 1
+            else
+                dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
+            fi
+            [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
+            ;;
+        arch | manjaro | parch)
+            pacman -Syu --noconfirm postgresql >&2 || return 1
+            if [[ ! -f /var/lib/postgres/data/PG_VERSION ]]; then
+                sudo -u postgres initdb -D /var/lib/postgres/data >&2 || return 1
+            fi
+            ;;
+        opensuse-tumbleweed | opensuse-leap)
+            zypper -q install -y postgresql-server postgresql-contrib >&2 || return 1
+            if [[ ! -f /var/lib/pgsql/data/PG_VERSION ]]; then
+                install -d -o postgres -g postgres -m 700 /var/lib/pgsql/data >&2 || return 1
+                su - postgres -c "initdb -D /var/lib/pgsql/data" >&2 || return 1
+            fi
+            ;;
+        alpine)
+            apk add --no-cache postgresql postgresql-contrib >&2 || return 1
+            if [[ ! -f /var/lib/postgresql/data/PG_VERSION ]]; then
+                /etc/init.d/postgresql setup >&2 || return 1
+            fi
+            rc-update add postgresql default >&2 2> /dev/null || true
+            rc-service postgresql start >&2 || return 1
+            ;;
+        *)
+            echo -e "${red}Unsupported distro for automatic PostgreSQL install: ${release}${plain}" >&2
+            return 1
+            ;;
+    esac
+
+    if [[ "${release}" != "alpine" ]]; then
+        systemctl enable --now postgresql >&2 || return 1
+    fi
+
+    local i
+    for i in 1 2 3 4 5; do
+        sudo -u postgres psql -tAc 'SELECT 1' > /dev/null 2>&1 && break
+        sleep 1
+    done
+
+    local existing_owner=""
+    existing_owner=$(sudo -u postgres psql -tAc \
+        "SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
+        | tr -d '[:space:]')
+    if [[ -n "${existing_owner}" && "${existing_owner}" != "postgres" ]]; then
+        pg_user="${existing_owner}"
+    else
+        pg_user=$(gen_random_string 8)
+    fi
+
+    sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${pg_user}'" 2> /dev/null \
+        | grep -q 1 \
+        || sudo -u postgres psql -c "CREATE USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
+
+    sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
+        | grep -q 1 \
+        || sudo -u postgres psql -c "CREATE DATABASE \"${pg_db}\" OWNER \"${pg_user}\";" >&2 || return 1
+
+    sudo -u postgres psql -c "ALTER USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
+
+    local pg_pass_enc
+    pg_pass_enc=$(printf '%s' "${pg_pass}" | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/@/%40/g' -e 's|/|%2F|g' -e 's/?/%3F/g' -e 's/#/%23/g')
+
+    echo "postgres://${pg_user}:${pg_pass_enc}@${pg_host}:${pg_port}/${pg_db}?sslmode=disable"
+    return 0
+}
+
+# Installs the PostgreSQL client tools (pg_dump/pg_restore) used by in-panel backup.
+pg_ensure_client() {
+    if command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1; then
+        return 0
+    fi
+    echo -e "${yellow}Installing PostgreSQL client tools (pg_dump/pg_restore)...${plain}" >&2
+    case "${release}" in
+        ubuntu | debian | armbian)
+            apt-get update >&2 && apt-get install -y -q postgresql-client >&2 || return 1
+            ;;
+        fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
+            dnf install -y -q postgresql >&2 || return 1
+            ;;
+        centos)
+            if [[ "${VERSION_ID}" =~ ^7 ]]; then
+                yum install -y postgresql >&2 || return 1
+            else
+                dnf install -y -q postgresql >&2 || return 1
+            fi
+            ;;
+        arch | manjaro | parch)
+            pacman -Sy --noconfirm postgresql >&2 || return 1
+            ;;
+        opensuse-tumbleweed | opensuse-leap)
+            zypper -q install -y postgresql >&2 || return 1
+            ;;
+        alpine)
+            apk add --no-cache postgresql-client >&2 || return 1
+            ;;
+        *)
+            return 1
+            ;;
+    esac
+    command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1
+}
+
+# Writes XUI_DB_TYPE/XUI_DB_DSN into the service env file, preserving other entries.
+pg_write_env() {
+    local dsn="$1" envfile
+    envfile="$(xui_env_file_path)"
+    install -d -m 755 "$(dirname "$envfile")"
+    touch "$envfile"
+    sed -i '/^XUI_DB_TYPE=/d; /^XUI_DB_DSN=/d' "$envfile"
+    {
+        echo "XUI_DB_TYPE=postgres"
+        echo "XUI_DB_DSN=${dsn}"
+    } >> "$envfile"
+    chmod 600 "$envfile"
+}
+
+pg_install_server_action() {
+    if postgresql_installed; then
+        LOGI "PostgreSQL already appears to be installed on this system."
+        confirm "Run setup anyway (ensures the xui database/user exist)?" "n" || return 0
+    fi
+    LOGI "Installing PostgreSQL server and creating a dedicated user/database..."
+    local dsn
+    dsn=$(pg_install_local)
+    if [[ $? -ne 0 || -z "$dsn" ]]; then
+        LOGE "PostgreSQL installation failed."
+        return 1
+    fi
+    PG_LAST_DSN="$dsn"
+    pg_ensure_client || LOGE "Could not install pg_dump/pg_restore (panel DB backup may be unavailable)."
+    echo ""
+    LOGI "PostgreSQL is installed and ready."
+    echo -e "${green}Connection DSN:${plain} ${dsn}"
+    echo -e "${yellow}Use option 2 to migrate your SQLite data and switch the panel to PostgreSQL.${plain}"
+}
+
+# Copies the current SQLite data into PostgreSQL, then switches the panel over.
+migrate_to_postgres() {
+    if [[ ! -x "${xui_folder}/x-ui" ]]; then
+        LOGE "x-ui is not installed."
+        return 1
+    fi
+    echo ""
+    echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
+    echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
+    echo -e "${yellow}The destination PostgreSQL database must be empty.${plain}"
+    confirm "Continue?" "n" || return 0
+
+    local dsn="" pg_mode
+    if [[ -n "$PG_LAST_DSN" ]]; then
+        echo -e "A PostgreSQL database was created in this session:"
+        echo -e "  ${green}${PG_LAST_DSN}${plain}"
+        confirm "Migrate into this database?" "y" && dsn="$PG_LAST_DSN"
+    fi
+
+    if [[ -z "$dsn" ]]; then
+        echo ""
+        echo -e "${green}\t1.${plain} Install PostgreSQL locally and create a dedicated user/db (recommended)"
+        echo -e "${green}\t2.${plain} Use an existing PostgreSQL server (enter DSN)"
+        read -rp "Choose [1]: " pg_mode
+        pg_mode="${pg_mode:-1}"
+        if [[ "$pg_mode" == "2" ]]; then
+            while [[ -z "$dsn" ]]; do
+                read -rp "Enter PostgreSQL DSN (postgres://user:pass@host:port/dbname?sslmode=disable): " dsn
+                dsn="${dsn// /}"
+            done
+        else
+            LOGI "Installing PostgreSQL locally (this may take a moment)..."
+            dsn=$(pg_install_local)
+            if [[ $? -ne 0 || -z "$dsn" ]]; then
+                LOGE "PostgreSQL installation failed. Aborting migration."
+                return 1
+            fi
+            PG_LAST_DSN="$dsn"
+        fi
+    fi
+
+    pg_ensure_client || LOGE "Could not install pg_dump/pg_restore (in-panel DB backup/restore may be unavailable)."
+
+    LOGI "Stopping panel to take a consistent snapshot..."
+    stop 0 > /dev/null 2>&1
+
+    echo ""
+    LOGI "Migrating data into PostgreSQL..."
+    if ! ${xui_folder}/x-ui migrate-db --dsn "$dsn"; then
+        LOGE "Migration failed. The panel was NOT switched to PostgreSQL."
+        start 0 > /dev/null 2>&1
+        return 1
+    fi
+
+    pg_write_env "$dsn"
+    LOGI "Wrote database settings to $(xui_env_file_path) (XUI_DB_TYPE=postgres)."
+    LOGI "Restarting panel on PostgreSQL..."
+    restart 0
+    sleep 1
+    if check_status; then
+        LOGI "Migration complete. The panel is now running on PostgreSQL."
+    else
+        LOGE "Panel did not come up. Check logs (option 16). Your SQLite data is left intact."
+    fi
+}
+
+postgresql_menu() {
+    echo -e "${green}\t1.${plain} ${green}Install${plain} PostgreSQL (server + client + xui db)"
+    echo -e "${green}\t2.${plain} Migrate SQLite ${green}->${plain} PostgreSQL"
+    echo -e "${green}\t3.${plain} Status (clusters & port 5432)"
+    echo -e "${green}\t4.${plain} ${green}Start${plain} PostgreSQL"
+    echo -e "${green}\t5.${plain} ${red}Stop${plain} PostgreSQL"
+    echo -e "${green}\t6.${plain} Restart PostgreSQL"
+    echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
+    echo -e "${green}\t8.${plain} View PostgreSQL Log"
+    echo -e "${green}\t0.${plain} Back to Main Menu"
+    read -rp "Choose an option: " choice
+    case "$choice" in
+        0)
+            show_menu
+            ;;
+        1)
+            pg_install_server_action
+            postgresql_menu
+            ;;
+        2)
+            migrate_to_postgres
+            postgresql_menu
+            ;;
+        3)
+            postgresql_status
+            postgresql_menu
+            ;;
+        4)
+            postgresql_start
+            postgresql_menu
+            ;;
+        5)
+            postgresql_stop
+            postgresql_menu
+            ;;
+        6)
+            postgresql_restart
+            postgresql_menu
+            ;;
+        7)
+            postgresql_enable
+            postgresql_menu
+            ;;
+        8)
+            postgresql_log
+            postgresql_menu
+            ;;
+        *)
+            echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
+            postgresql_menu
+            ;;
+    esac
+}
+
 show_usage() {
     echo -e "┌────────────────────────────────────────────────────────────────┐
 │  ${blue}x-ui control menu usages (subcommands):${plain}                       │
@@ -2342,10 +2751,12 @@ show_menu() {
 │  ${green}24.${plain} Enable BBR                                │
 │  ${green}25.${plain} Update Geo Files                          │
 │  ${green}26.${plain} Speedtest by Ookla                        │
+│────────────────────────────────────────────────│
+│  ${green}27.${plain} PostgreSQL Management                     │
 ╚────────────────────────────────────────────────╝
 "
     show_status
-    echo && read -rp "Please enter your selection [0-26]: " num
+    echo && read -rp "Please enter your selection [0-27]: " num
 
     case "${num}" in
         0)
@@ -2429,8 +2840,11 @@ show_menu() {
         26)
             run_speedtest
             ;;
+        27)
+            postgresql_menu
+            ;;
         *)
-            LOGE "Please enter the correct number [0-26]"
+            LOGE "Please enter the correct number [0-27]"
             ;;
     esac
 }