12 Коміти e02f78ac68 ... 36b2a58675

Автор SHA1 Опис Дата
  Peter Liu 36b2a58675 feat: Add NordVPN NordLynx (WireGuard) integration (#3827) 1 тиждень тому
  MHSanaei 59e9859225 Enable CodeQL file coverage on PRs 1 тиждень тому
  dependabot[bot] 4e5f144def Bump actions/checkout from 4 to 6 (#4045) 1 тиждень тому
  Sanaei ea53da9341 Add SSRF protection (#4044) 1 тиждень тому
  MHSanaei 3e1a102e9d Add CodeQL Advanced GitHub Actions workflow 1 тиждень тому
  zhuzn d580086361 feat add clash yaml convert (#3916) 1 тиждень тому
  HamidReza Sadeghzadeh 1e3b366fba revert: Disconnect client due to exceeded IP limit (#3948) 1 тиждень тому
  Troodi c2a2a36f56 Fix geosite:ru rule (Normalization to RU vs lowercase ru) (#3971) 1 тиждень тому
  Andrew Smirnov e986a133f8 Add new hourly reset traffic (#3966) 1 тиждень тому
  Vladislav Tupikin 7466916e02 Add custom geosite/geoip URL sources (#3980) 1 тиждень тому
  Nikita Nemirovsky 96b568b838 fix(sub): use safe type assertion for xhttp mode field (#3990) 1 тиждень тому
  lolka1333 fec714a243 fix: enhance WebSocket stability, resolve XHTTP configurations and fix UI loading shifts (#3997) 1 тиждень тому
67 змінених файлів з 4023 додано та 222 видалено
  1. 45 0
      .github/workflows/codeql.yml
  2. 8 0
      README.ar_EG.md
  3. 8 0
      README.es_ES.md
  4. 8 0
      README.fa_IR.md
  5. 8 0
      README.md
  6. 8 0
      README.ru_RU.md
  7. 8 0
      README.zh_CN.md
  8. 2 2
      database/db.go
  9. 12 0
      database/model/model.go
  10. 1 1
      docker-compose.yml
  11. 2 2
      go.mod
  12. 1 1
      install.sh
  13. 11 2
      sub/sub.go
  14. 385 0
      sub/subClashService.go
  15. 38 7
      sub/subController.go
  16. 13 15
      sub/subService.go
  17. 1 1
      update.sh
  18. 0 0
      web/assets/css/custom.min.css
  19. 24 1
      web/assets/js/model/dbinbound.js
  20. 3 0
      web/assets/js/model/setting.js
  21. 7 0
      web/assets/js/subscription.js
  22. 5 3
      web/controller/api.go
  23. 180 0
      web/controller/custom_geo.go
  24. 11 2
      web/controller/util.go
  25. 4 2
      web/controller/websocket.go
  26. 28 0
      web/controller/xray_setting.go
  27. 10 0
      web/entity/entity.go
  28. 2 0
      web/html/form/inbound.html
  29. 3 0
      web/html/form/stream/stream_xhttp.html
  30. 115 37
      web/html/inbounds.html
  31. 222 6
      web/html/index.html
  32. 3 3
      web/html/modals/client_bulk_modal.html
  33. 4 4
      web/html/modals/client_modal.html
  34. 306 0
      web/html/modals/nord_modal.html
  35. 4 7
      web/html/settings.html
  36. 60 26
      web/html/settings/panel/subscription/general.html
  37. 22 4
      web/html/settings/panel/subscription/json.html
  38. 15 2
      web/html/settings/panel/subscription/subpage.html
  39. 19 0
      web/html/settings/xray/basics.html
  40. 2 0
      web/html/settings/xray/outbounds.html
  41. 59 5
      web/html/xray.html
  42. 133 0
      web/job/check_client_ip_job.go
  43. 1 1
      web/job/periodic_traffic_reset_job.go
  44. 19 13
      web/job/xray_traffic_job.go
  45. 760 0
      web/service/custom_geo.go
  46. 348 0
      web/service/custom_geo_test.go
  47. 10 0
      web/service/inbound.go
  48. 145 0
      web/service/nord.go
  49. 56 0
      web/service/server.go
  50. 51 15
      web/service/setting.go
  51. 22 18
      web/service/xray.go
  52. 54 0
      web/translation/translate.ar_EG.toml
  53. 54 0
      web/translation/translate.en_US.toml
  54. 54 0
      web/translation/translate.es_ES.toml
  55. 54 0
      web/translation/translate.fa_IR.toml
  56. 54 0
      web/translation/translate.id_ID.toml
  57. 54 0
      web/translation/translate.ja_JP.toml
  58. 54 0
      web/translation/translate.pt_BR.toml
  59. 55 1
      web/translation/translate.ru_RU.toml
  60. 54 0
      web/translation/translate.tr_TR.toml
  61. 54 0
      web/translation/translate.uk_UA.toml
  62. 54 0
      web/translation/translate.vi_VN.toml
  63. 54 0
      web/translation/translate.zh_CN.toml
  64. 54 0
      web/translation/translate.zh_TW.toml
  65. 28 4
      web/web.go
  66. 59 37
      web/websocket/hub.go
  67. 21 0
      web/websocket/notifier.go

+ 45 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,45 @@
+name: "CodeQL Advanced"
+
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: '18 2 * * 2'
+
+jobs:
+  analyze:
+    name: Analyze (${{ matrix.language }})
+    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+    env:
+      CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
+    permissions:
+      security-events: write
+      packages: read
+      actions: read
+      contents: read
+
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+        - language: actions
+          build-mode: none
+        - language: go
+          build-mode: autobuild
+        - language: javascript-typescript
+          build-mode: none
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v6
+
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v4
+      with:
+        languages: ${{ matrix.language }}
+        build-mode: ${{ matrix.build-mode }}
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v4
+      with:
+        category: "/language:${{matrix.language}}"

+ 8 - 0
README.ar_EG.md

@@ -22,6 +22,14 @@
 
 
 كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
 كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
 
 
+## مصادر DAT مخصصة GeoSite / GeoIP
+
+يمكن للمسؤولين إضافة ملفات `.dat` لـ GeoSite وGeoIP من عناوين URL في اللوحة (نفس أسلوب تحديث ملفات الجيو المدمجة). تُحفظ الملفات بجانب ثنائي Xray (`XUI_BIN_FOLDER`، الافتراضي `bin/`) بأسماء ثابتة: `geosite_<alias>.dat` و`geoip_<alias>.dat`.
+
+**التوجيه:** استخدم الصيغة `ext:`، مثل `ext:geosite_myalias.dat:tag` أو `ext:geoip_myalias.dat:tag`، حيث `tag` اسم قائمة داخل ملف DAT (كما في `ext:geoip_IR.dat:ir`).
+
+**الأسماء المحجوزة:** يُقارَن شكل مُطبَّع فقط لمعرفة التحفظ (`strings.ToLower`، `-` → `_`). لا تُعاد كتابة الأسماء التي يدخلها المستخدم أو سجلات قاعدة البيانات؛ يجب أن تطابق `^[a-z0-9_-]+$`. مثلاً `geoip-ir` و`geoip_ir` يصطدمان بنفس الحجز.
+
 ## البدء السريع
 ## البدء السريع
 
 
 ```
 ```

+ 8 - 0
README.es_ES.md

@@ -22,6 +22,14 @@
 
 
 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.
 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.
 
 
+## Fuentes DAT personalizadas GeoSite / GeoIP
+
+Los administradores pueden añadir archivos `.dat` de GeoSite y GeoIP desde URLs en el panel (mismo flujo que los geoficheros integrados). Los archivos se guardan junto al binario de Xray (`XUI_BIN_FOLDER`, por defecto `bin/`) con nombres fijos: `geosite_<alias>.dat` y `geoip_<alias>.dat`.
+
+**Enrutamiento:** use la forma `ext:`, por ejemplo `ext:geosite_myalias.dat:tag` o `ext:geoip_myalias.dat:tag`, donde `tag` es un nombre de lista dentro del DAT (igual que en archivos regionales como `ext:geoip_IR.dat:ir`).
+
+**Alias reservados:** solo para comprobar si un nombre está reservado se compara una forma normalizada (`strings.ToLower`, `-` → `_`). Los alias introducidos y los nombres en la base de datos no se reescriben; deben cumplir `^[a-z0-9_-]+$`. Por ejemplo, `geoip-ir` y `geoip_ir` chocan con la misma entrada reservada.
+
 ## Inicio Rápido
 ## Inicio Rápido
 
 
 ```
 ```

+ 8 - 0
README.fa_IR.md

@@ -22,6 +22,14 @@
 
 
 به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
 به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
 
 
+## منابع DAT سفارشی GeoSite / GeoIP
+
+سرپرستان می‌توانند از طریق پنل فایل‌های `.dat` GeoSite و GeoIP را از URL اضافه کنند (همان الگوی به‌روزرسانی ژئوفایل‌های داخلی). فایل‌ها در کنار باینری Xray (`XUI_BIN_FOLDER`، پیش‌فرض `bin/`) با نام‌های ثابت `geosite_<alias>.dat` و `geoip_<alias>.dat` ذخیره می‌شوند.
+
+**مسیریابی:** از شکل `ext:` استفاده کنید، مثلاً `ext:geosite_myalias.dat:tag` یا `ext:geoip_myalias.dat:tag`؛ `tag` نام لیست داخل همان DAT است (مانند `ext:geoip_IR.dat:ir`).
+
+**نام‌های رزرو:** فقط برای تشخیص رزرو بودن، نسخه نرمال‌شده (`strings.ToLower`، `-` → `_`) مقایسه می‌شود. نام‌های واردشده و رکورد پایگاه داده بازنویسی نمی‌شوند و باید با `^[a-z0-9_-]+$` سازگار باشند؛ مثلاً `geoip-ir` و `geoip_ir` به یک رزرو یکسان می‌خورند.
+
 ## شروع سریع
 ## شروع سریع
 
 
 ```
 ```

+ 8 - 0
README.md

@@ -22,6 +22,14 @@
 
 
 As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
 As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
 
 
+## Custom GeoSite / GeoIP DAT sources
+
+Administrators can add custom GeoSite and GeoIP `.dat` files from URLs in the panel (same workflow as updating built-in geofiles). Files are stored under the same directory as the Xray binary (`XUI_BIN_FOLDER`, default `bin/`) with deterministic names: `geosite_<alias>.dat` and `geoip_<alias>.dat`.
+
+**Routing:** Xray resolves extra lists using the `ext:` form, for example `ext:geosite_myalias.dat:tag` or `ext:geoip_myalias.dat:tag`, where `tag` is a list name inside that DAT file (same pattern as built-in regional files such as `ext:geoip_IR.dat:ir`).
+
+**Reserved aliases:** Only for deciding whether a name is reserved, the panel compares a normalized form of the alias (`strings.ToLower`, `-` → `_`). User-entered aliases and generated file names are not rewritten in the database; they must still match `^[a-z0-9_-]+$`. For example, `geoip-ir` and `geoip_ir` collide with the same reserved entry.
+
 ## Quick Start
 ## Quick Start
 
 
 ```bash
 ```bash

+ 8 - 0
README.ru_RU.md

@@ -22,6 +22,14 @@
 
 
 Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
 Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
 
 
+## Пользовательские GeoSite / GeoIP (DAT)
+
+В панели можно задать свои источники `.dat` по URL (тот же сценарий, что и для встроенных геофайлов). Файлы сохраняются в каталоге с бинарником Xray (`XUI_BIN_FOLDER`, по умолчанию `bin/`) как `geosite_<alias>.dat` и `geoip_<alias>.dat`.
+
+**Маршрутизация:** в правилах используйте форму `ext:имя_файла.dat:тег`, например `ext:geosite_myalias.dat:tag` (как у региональных списков `ext:geoip_IR.dat:ir`).
+
+**Зарезервированные псевдонимы:** только для проверки на резерв используется нормализованная форма (`strings.ToLower`, `-` → `_`). Введённые пользователем псевдонимы и имена файлов в БД не переписываются и должны соответствовать `^[a-z0-9_-]+$`. Например, `geoip-ir` и `geoip_ir` попадают под одну и ту же зарезервированную запись.
+
 ## Быстрый старт
 ## Быстрый старт
 
 
 ```
 ```

+ 8 - 0
README.zh_CN.md

@@ -22,6 +22,14 @@
 
 
 作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
 作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
 
 
+## 自定义 GeoSite / GeoIP(DAT)
+
+管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_<alias>.dat` 和 `geoip_<alias>.dat`。
+
+**路由:** 在规则中使用 `ext:` 形式,例如 `ext:geosite_myalias.dat:tag` 或 `ext:geoip_myalias.dat:tag`,其中 `tag` 为该 DAT 文件内的列表名(与内置区域文件如 `ext:geoip_IR.dat:ir` 相同)。
+
+**保留别名:** 仅在为判断是否命中保留名时,会对别名做规范化比较(`strings.ToLower`,`-` → `_`)。用户输入的别名与数据库中的名称不会被改写,且须符合 `^[a-z0-9_-]+$`。例如 `geoip-ir` 与 `geoip_ir` 视为同一保留项。
+
 ## 快速开始
 ## 快速开始
 
 
 ```
 ```

+ 2 - 2
database/db.go

@@ -38,6 +38,7 @@ func initModels() error {
 		&model.InboundClientIps{},
 		&model.InboundClientIps{},
 		&xray.ClientTraffic{},
 		&xray.ClientTraffic{},
 		&model.HistoryOfSeeders{},
 		&model.HistoryOfSeeders{},
+		&model.CustomGeoResource{},
 	}
 	}
 	for _, model := range models {
 	for _, model := range models {
 		if err := db.AutoMigrate(model); err != nil {
 		if err := db.AutoMigrate(model); err != nil {
@@ -175,9 +176,8 @@ func GetDB() *gorm.DB {
 	return db
 	return db
 }
 }
 
 
-// IsNotFound checks if the given error is a GORM record not found error.
 func IsNotFound(err error) bool {
 func IsNotFound(err error) bool {
-	return err == gorm.ErrRecordNotFound
+	return errors.Is(err, gorm.ErrRecordNotFound)
 }
 }
 
 
 // IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
 // IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.

+ 12 - 0
database/model/model.go

@@ -104,6 +104,18 @@ type Setting struct {
 	Value string `json:"value" form:"value"`
 	Value string `json:"value" form:"value"`
 }
 }
 
 
+type CustomGeoResource struct {
+	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
+	Alias         string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
+	Url           string `json:"url" gorm:"not null"`
+	LocalPath     string `json:"localPath" gorm:"column:local_path"`
+	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
+	LastModified  string `json:"lastModified" gorm:"column:last_modified"`
+	CreatedAt     int64  `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
+	UpdatedAt     int64  `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
+}
+
 // Client represents a client configuration for Xray inbounds with traffic limits and settings.
 // Client represents a client configuration for Xray inbounds with traffic limits and settings.
 type Client struct {
 type Client struct {
 	ID         string `json:"id"`                           // Unique client identifier
 	ID         string `json:"id"`                           // Unique client identifier

+ 1 - 1
docker-compose.yml

@@ -13,4 +13,4 @@ services:
       XUI_ENABLE_FAIL2BAN: "true"
       XUI_ENABLE_FAIL2BAN: "true"
     tty: true
     tty: true
     network_mode: host
     network_mode: host
-    restart: unless-stopped
+    restart: unless-stopped

+ 2 - 2
go.mod

@@ -8,6 +8,7 @@ require (
 	github.com/gin-gonic/gin v1.12.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.13
 	github.com/go-ldap/ldap/v3 v3.4.13
 	github.com/goccy/go-json v0.10.6
 	github.com/goccy/go-json v0.10.6
+	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	github.com/joho/godotenv v1.5.1
@@ -26,6 +27,7 @@ require (
 	golang.org/x/sys v0.42.0
 	golang.org/x/sys v0.42.0
 	golang.org/x/text v0.35.0
 	golang.org/x/text v0.35.0
 	google.golang.org/grpc v1.80.0
 	google.golang.org/grpc v1.80.0
+	google.golang.org/protobuf v1.36.11
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.1
 	gorm.io/gorm v1.31.1
 )
 )
@@ -47,7 +49,6 @@ require (
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-playground/validator/v10 v10.30.2 // indirect
 	github.com/go-playground/validator/v10 v10.30.2 // indirect
-	github.com/goccy/go-yaml v1.19.2 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
@@ -96,7 +97,6 @@ require (
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
-	google.golang.org/protobuf v1.36.11 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )
 )

+ 1 - 1
install.sh

@@ -602,7 +602,7 @@ prompt_and_setup_ssl() {
 
 
         # 3.1 Request Domain to compose Panel URL later
         # 3.1 Request Domain to compose Panel URL later
         read -rp "Please enter domain name certificate issued for: " custom_domain
         read -rp "Please enter domain name certificate issued for: " custom_domain
-        custom_domain="${custom_domain// /}" # Убираем пробелы
+        custom_domain="${custom_domain// /}" # Remove spaces
 
 
         # 3.2 Loop for Certificate Path
         # 3.2 Loop for Certificate Path
         while true; do
         while true; do

+ 11 - 2
sub/sub.go

@@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	// Determine if JSON subscription endpoint is enabled
+	ClashPath, err := s.settingService.GetSubClashPath()
+	if err != nil {
+		return nil, err
+	}
+
 	subJsonEnable, err := s.settingService.GetSubJsonEnable()
 	subJsonEnable, err := s.settingService.GetSubJsonEnable()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	subClashEnable, err := s.settingService.GetSubClashEnable()
+	if err != nil {
+		return nil, err
+	}
+
 	// Set base_path based on LinksPath for template rendering
 	// Set base_path based on LinksPath for template rendering
 	// Ensure LinksPath ends with "/" for proper asset URL generation
 	// Ensure LinksPath ends with "/" for proper asset URL generation
 	basePath := LinksPath
 	basePath := LinksPath
@@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	g := engine.Group("/")
 	g := engine.Group("/")
 
 
 	s.sub = NewSUBController(
 	s.sub = NewSUBController(
-		g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
+		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
 		SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
 		SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 
 

+ 385 - 0
sub/subClashService.go

@@ -0,0 +1,385 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/goccy/go-json"
+	yaml "github.com/goccy/go-yaml"
+
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v2/xray"
+)
+
+type SubClashService struct {
+	inboundService service.InboundService
+	SubService     *SubService
+}
+
+type ClashConfig struct {
+	Proxies     []map[string]any `yaml:"proxies"`
+	ProxyGroups []map[string]any `yaml:"proxy-groups"`
+	Rules       []string         `yaml:"rules"`
+}
+
+func NewSubClashService(subService *SubService) *SubClashService {
+	return &SubClashService{SubService: subService}
+}
+
+func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
+	inbounds, err := s.SubService.getInboundsBySubId(subId)
+	if err != nil || len(inbounds) == 0 {
+		return "", "", err
+	}
+
+	var traffic xray.ClientTraffic
+	var clientTraffics []xray.ClientTraffic
+	var proxies []map[string]any
+
+	for _, inbound := range inbounds {
+		clients, err := s.inboundService.GetClients(inbound)
+		if err != nil {
+			logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
+		}
+		if clients == nil {
+			continue
+		}
+		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
+			listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
+			if err == nil {
+				inbound.Listen = listen
+				inbound.Port = port
+				inbound.StreamSettings = streamSettings
+			}
+		}
+		for _, client := range clients {
+			if client.Enable && client.SubID == subId {
+				clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
+				proxies = append(proxies, s.getProxies(inbound, client, host)...)
+			}
+		}
+	}
+
+	if len(proxies) == 0 {
+		return "", "", nil
+	}
+
+	for index, clientTraffic := range clientTraffics {
+		if index == 0 {
+			traffic.Up = clientTraffic.Up
+			traffic.Down = clientTraffic.Down
+			traffic.Total = clientTraffic.Total
+			if clientTraffic.ExpiryTime > 0 {
+				traffic.ExpiryTime = clientTraffic.ExpiryTime
+			}
+		} else {
+			traffic.Up += clientTraffic.Up
+			traffic.Down += clientTraffic.Down
+			if traffic.Total == 0 || clientTraffic.Total == 0 {
+				traffic.Total = 0
+			} else {
+				traffic.Total += clientTraffic.Total
+			}
+			if clientTraffic.ExpiryTime != traffic.ExpiryTime {
+				traffic.ExpiryTime = 0
+			}
+		}
+	}
+
+	proxyNames := make([]string, 0, len(proxies)+1)
+	for _, proxy := range proxies {
+		if name, ok := proxy["name"].(string); ok && name != "" {
+			proxyNames = append(proxyNames, name)
+		}
+	}
+	proxyNames = append(proxyNames, "DIRECT")
+
+	config := ClashConfig{
+		Proxies: proxies,
+		ProxyGroups: []map[string]any{{
+			"name":    "PROXY",
+			"type":    "select",
+			"proxies": proxyNames,
+		}},
+		Rules: []string{"MATCH,PROXY"},
+	}
+
+	finalYAML, err := yaml.Marshal(config)
+	if err != nil {
+		return "", "", err
+	}
+
+	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	return string(finalYAML), header, nil
+}
+
+func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
+	stream := s.streamData(inbound.StreamSettings)
+	externalProxies, ok := stream["externalProxy"].([]any)
+	if !ok || len(externalProxies) == 0 {
+		externalProxies = []any{map[string]any{
+			"forceTls": "same",
+			"dest":     host,
+			"port":     float64(inbound.Port),
+			"remark":   "",
+		}}
+	}
+	delete(stream, "externalProxy")
+
+	proxies := make([]map[string]any, 0, len(externalProxies))
+	for _, ep := range externalProxies {
+		extPrxy := ep.(map[string]any)
+		workingInbound := *inbound
+		workingInbound.Listen = extPrxy["dest"].(string)
+		workingInbound.Port = int(extPrxy["port"].(float64))
+		workingStream := cloneMap(stream)
+
+		switch extPrxy["forceTls"].(string) {
+		case "tls":
+			if workingStream["security"] != "tls" {
+				workingStream["security"] = "tls"
+				workingStream["tlsSettings"] = map[string]any{}
+			}
+		case "none":
+			if workingStream["security"] != "none" {
+				workingStream["security"] = "none"
+				delete(workingStream, "tlsSettings")
+				delete(workingStream, "realitySettings")
+			}
+		}
+
+		proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
+		if len(proxy) > 0 {
+			proxies = append(proxies, proxy)
+		}
+	}
+	return proxies
+}
+
+func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
+	proxy := map[string]any{
+		"name":   s.SubService.genRemark(inbound, client.Email, extraRemark),
+		"server": inbound.Listen,
+		"port":   inbound.Port,
+		"udp":    true,
+	}
+
+	network, _ := stream["network"].(string)
+	if !s.applyTransport(proxy, network, stream) {
+		return nil
+	}
+
+	switch inbound.Protocol {
+	case model.VMESS:
+		proxy["type"] = "vmess"
+		proxy["uuid"] = client.ID
+		proxy["alterId"] = 0
+		cipher := client.Security
+		if cipher == "" {
+			cipher = "auto"
+		}
+		proxy["cipher"] = cipher
+	case model.VLESS:
+		proxy["type"] = "vless"
+		proxy["uuid"] = client.ID
+		if client.Flow != "" && network == "tcp" {
+			proxy["flow"] = client.Flow
+		}
+		var inboundSettings map[string]any
+		json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
+			proxy["packet-encoding"] = encryption
+		}
+	case model.Trojan:
+		proxy["type"] = "trojan"
+		proxy["password"] = client.Password
+	case model.Shadowsocks:
+		proxy["type"] = "ss"
+		proxy["password"] = client.Password
+		var inboundSettings map[string]any
+		json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		method, _ := inboundSettings["method"].(string)
+		if method == "" {
+			return nil
+		}
+		proxy["cipher"] = method
+		if strings.HasPrefix(method, "2022") {
+			if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" {
+				proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password)
+			}
+		}
+	default:
+		return nil
+	}
+
+	security, _ := stream["security"].(string)
+	if !s.applySecurity(proxy, security, stream) {
+		return nil
+	}
+
+	return proxy
+}
+
+func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
+	switch network {
+	case "", "tcp":
+		proxy["network"] = "tcp"
+		tcp, _ := stream["tcpSettings"].(map[string]any)
+		if tcp != nil {
+			header, _ := tcp["header"].(map[string]any)
+			if header != nil {
+				typeStr, _ := header["type"].(string)
+				if typeStr != "" && typeStr != "none" {
+					return false
+				}
+			}
+		}
+		return true
+	case "ws":
+		proxy["network"] = "ws"
+		ws, _ := stream["wsSettings"].(map[string]any)
+		wsOpts := map[string]any{}
+		if ws != nil {
+			if path, ok := ws["path"].(string); ok && path != "" {
+				wsOpts["path"] = path
+			}
+			host := ""
+			if v, ok := ws["host"].(string); ok && v != "" {
+				host = v
+			} else if headers, ok := ws["headers"].(map[string]any); ok {
+				host = searchHost(headers)
+			}
+			if host != "" {
+				wsOpts["headers"] = map[string]any{"Host": host}
+			}
+		}
+		if len(wsOpts) > 0 {
+			proxy["ws-opts"] = wsOpts
+		}
+		return true
+	case "grpc":
+		proxy["network"] = "grpc"
+		grpc, _ := stream["grpcSettings"].(map[string]any)
+		grpcOpts := map[string]any{}
+		if grpc != nil {
+			if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
+				grpcOpts["grpc-service-name"] = serviceName
+			}
+		}
+		if len(grpcOpts) > 0 {
+			proxy["grpc-opts"] = grpcOpts
+		}
+		return true
+	default:
+		return false
+	}
+}
+
+func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
+	switch security {
+	case "", "none":
+		proxy["tls"] = false
+		return true
+	case "tls":
+		proxy["tls"] = true
+		tlsSettings, _ := stream["tlsSettings"].(map[string]any)
+		if tlsSettings != nil {
+			if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
+				proxy["servername"] = serverName
+				switch proxy["type"] {
+				case "trojan":
+					proxy["sni"] = serverName
+				}
+			}
+			if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
+				proxy["client-fingerprint"] = fingerprint
+			}
+		}
+		return true
+	case "reality":
+		proxy["tls"] = true
+		realitySettings, _ := stream["realitySettings"].(map[string]any)
+		if realitySettings == nil {
+			return false
+		}
+		if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" {
+			proxy["servername"] = serverName
+		}
+		realityOpts := map[string]any{}
+		if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" {
+			realityOpts["public-key"] = publicKey
+		}
+		if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" {
+			realityOpts["short-id"] = shortID
+		}
+		if len(realityOpts) > 0 {
+			proxy["reality-opts"] = realityOpts
+		}
+		if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
+			proxy["client-fingerprint"] = fingerprint
+		}
+		return true
+	default:
+		return false
+	}
+}
+
+func (s *SubClashService) streamData(stream string) map[string]any {
+	var streamSettings map[string]any
+	json.Unmarshal([]byte(stream), &streamSettings)
+	security, _ := streamSettings["security"].(string)
+	switch security {
+	case "tls":
+		if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
+			streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
+		}
+	case "reality":
+		if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
+			streamSettings["realitySettings"] = s.realityData(realitySettings)
+		}
+	}
+	delete(streamSettings, "sockopt")
+	return streamSettings
+}
+
+func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
+	tlsData := make(map[string]any, 1)
+	tlsClientSettings, _ := tData["settings"].(map[string]any)
+	tlsData["serverName"] = tData["serverName"]
+	tlsData["alpn"] = tData["alpn"]
+	if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
+		tlsData["fingerprint"] = fingerprint
+	}
+	return tlsData
+}
+
+func (s *SubClashService) realityData(rData map[string]any) map[string]any {
+	rDataOut := make(map[string]any, 1)
+	realityClientSettings, _ := rData["settings"].(map[string]any)
+	if publicKey, ok := realityClientSettings["publicKey"].(string); ok {
+		rDataOut["publicKey"] = publicKey
+	}
+	if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok {
+		rDataOut["fingerprint"] = fingerprint
+	}
+	if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 {
+		rDataOut["serverName"] = fmt.Sprint(serverNames[0])
+	}
+	if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 {
+		rDataOut["shortId"] = fmt.Sprint(shortIDs[0])
+	}
+	return rDataOut
+}
+
+func cloneMap(src map[string]any) map[string]any {
+	if src == nil {
+		return nil
+	}
+	dst := make(map[string]any, len(src))
+	for k, v := range src {
+		dst[k] = v
+	}
+	return dst
+}

+ 38 - 7
sub/subController.go

@@ -21,12 +21,15 @@ type SUBController struct {
 	subRoutingRules  string
 	subRoutingRules  string
 	subPath          string
 	subPath          string
 	subJsonPath      string
 	subJsonPath      string
+	subClashPath     string
 	jsonEnabled      bool
 	jsonEnabled      bool
+	clashEnabled     bool
 	subEncrypt       bool
 	subEncrypt       bool
 	updateInterval   string
 	updateInterval   string
 
 
-	subService     *SubService
-	subJsonService *SubJsonService
+	subService      *SubService
+	subJsonService  *SubJsonService
+	subClashService *SubClashService
 }
 }
 
 
 // NewSUBController creates a new subscription controller with the given configuration.
 // NewSUBController creates a new subscription controller with the given configuration.
@@ -34,7 +37,9 @@ func NewSUBController(
 	g *gin.RouterGroup,
 	g *gin.RouterGroup,
 	subPath string,
 	subPath string,
 	jsonPath string,
 	jsonPath string,
+	clashPath string,
 	jsonEnabled bool,
 	jsonEnabled bool,
+	clashEnabled bool,
 	encrypt bool,
 	encrypt bool,
 	showInfo bool,
 	showInfo bool,
 	rModel string,
 	rModel string,
@@ -60,12 +65,15 @@ func NewSUBController(
 		subRoutingRules:  subRoutingRules,
 		subRoutingRules:  subRoutingRules,
 		subPath:          subPath,
 		subPath:          subPath,
 		subJsonPath:      jsonPath,
 		subJsonPath:      jsonPath,
+		subClashPath:     clashPath,
 		jsonEnabled:      jsonEnabled,
 		jsonEnabled:      jsonEnabled,
+		clashEnabled:     clashEnabled,
 		subEncrypt:       encrypt,
 		subEncrypt:       encrypt,
 		updateInterval:   update,
 		updateInterval:   update,
 
 
-		subService:     sub,
-		subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+		subService:      sub,
+		subJsonService:  NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+		subClashService: NewSubClashService(sub),
 	}
 	}
 	a.initRouter(g)
 	a.initRouter(g)
 	return a
 	return a
@@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 		gJson := g.Group(a.subJsonPath)
 		gJson := g.Group(a.subJsonPath)
 		gJson.GET(":subid", a.subJsons)
 		gJson.GET(":subid", a.subJsons)
 	}
 	}
+	if a.clashEnabled {
+		gClash := g.Group(a.subClashPath)
+		gClash.GET(":subid", a.subClashs)
+	}
 }
 }
 
 
 // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
 // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
@@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
 		accept := c.GetHeader("Accept")
 		accept := c.GetHeader("Accept")
 		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
 		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
 			// Build page data in service
 			// Build page data in service
-			subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
+			subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
 			if !a.jsonEnabled {
 			if !a.jsonEnabled {
 				subJsonURL = ""
 				subJsonURL = ""
 			}
 			}
+			if !a.clashEnabled {
+				subClashURL = ""
+			}
 			// Get base_path from context (set by middleware)
 			// Get base_path from context (set by middleware)
 			basePath, exists := c.Get("base_path")
 			basePath, exists := c.Get("base_path")
 			if !exists {
 			if !exists {
@@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				// Remove trailing slash if exists, add subId, then add trailing slash
 				// Remove trailing slash if exists, add subId, then add trailing slash
 				basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
 				basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
 			}
 			}
-			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
+			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
 			c.HTML(200, "subpage.html", gin.H{
 			c.HTML(200, "subpage.html", gin.H{
 				"title":        "subscription.title",
 				"title":        "subscription.title",
 				"cur_ver":      config.GetVersion(),
 				"cur_ver":      config.GetVersion(),
@@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				"totalByte":    page.TotalByte,
 				"totalByte":    page.TotalByte,
 				"subUrl":       page.SubUrl,
 				"subUrl":       page.SubUrl,
 				"subJsonUrl":   page.SubJsonUrl,
 				"subJsonUrl":   page.SubJsonUrl,
+				"subClashUrl":  page.SubClashUrl,
 				"result":       page.Result,
 				"result":       page.Result,
 			})
 			})
 			return
 			return
@@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	if err != nil || len(jsonSub) == 0 {
 	if err != nil || len(jsonSub) == 0 {
 		c.String(400, "Error!")
 		c.String(400, "Error!")
 	} else {
 	} else {
-		// Add headers
 		profileUrl := a.subProfileUrl
 		profileUrl := a.subProfileUrl
 		if profileUrl == "" {
 		if profileUrl == "" {
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
@@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	}
 	}
 }
 }
 
 
+func (a *SUBController) subClashs(c *gin.Context) {
+	subId := c.Param("subid")
+	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
+	clashSub, header, err := a.subClashService.GetClash(subId, host)
+	if err != nil || len(clashSub) == 0 {
+		c.String(400, "Error!")
+	} else {
+		profileUrl := a.subProfileUrl
+		if profileUrl == "" {
+			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
+		}
+		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
+		c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
+	}
+}
+
 // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
 // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
 func (a *SUBController) ApplyCommonHeaders(
 func (a *SUBController) ApplyCommonHeaders(
 	c *gin.Context,
 	c *gin.Context,

+ 13 - 15
sub/subService.go

@@ -247,7 +247,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 			headers, _ := xhttp["headers"].(map[string]any)
 			headers, _ := xhttp["headers"].(map[string]any)
 			obj["host"] = searchHost(headers)
 			obj["host"] = searchHost(headers)
 		}
 		}
-		obj["mode"] = xhttp["mode"].(string)
+		obj["mode"], _ = xhttp["mode"].(string)
 	}
 	}
 	security, _ := stream["security"].(string)
 	security, _ := stream["security"].(string)
 	obj["tls"] = security
 	obj["tls"] = security
@@ -405,7 +405,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 			headers, _ := xhttp["headers"].(map[string]any)
 			headers, _ := xhttp["headers"].(map[string]any)
 			params["host"] = searchHost(headers)
 			params["host"] = searchHost(headers)
 		}
 		}
-		params["mode"] = xhttp["mode"].(string)
+		params["mode"], _ = xhttp["mode"].(string)
 	}
 	}
 	security, _ := stream["security"].(string)
 	security, _ := stream["security"].(string)
 	if security == "tls" {
 	if security == "tls" {
@@ -601,7 +601,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 			headers, _ := xhttp["headers"].(map[string]any)
 			headers, _ := xhttp["headers"].(map[string]any)
 			params["host"] = searchHost(headers)
 			params["host"] = searchHost(headers)
 		}
 		}
-		params["mode"] = xhttp["mode"].(string)
+		params["mode"], _ = xhttp["mode"].(string)
 	}
 	}
 	security, _ := stream["security"].(string)
 	security, _ := stream["security"].(string)
 	if security == "tls" {
 	if security == "tls" {
@@ -800,7 +800,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			headers, _ := xhttp["headers"].(map[string]any)
 			headers, _ := xhttp["headers"].(map[string]any)
 			params["host"] = searchHost(headers)
 			params["host"] = searchHost(headers)
 		}
 		}
-		params["mode"] = xhttp["mode"].(string)
+		params["mode"], _ = xhttp["mode"].(string)
 	}
 	}
 
 
 	security, _ := stream["security"].(string)
 	security, _ := stream["security"].(string)
@@ -1031,6 +1031,7 @@ type PageData struct {
 	TotalByte    int64
 	TotalByte    int64
 	SubUrl       string
 	SubUrl       string
 	SubJsonUrl   string
 	SubJsonUrl   string
+	SubClashUrl  string
 	Result       []string
 	Result       []string
 }
 }
 
 
@@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 
 
 // BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
 // BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
 // It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
 // It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
-func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
-	// Input validation
+func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
 	if subId == "" {
 	if subId == "" {
-		return "", ""
+		return "", "", ""
 	}
 	}
 
 
-	// Get configured URIs first (highest priority)
 	configuredSubURI, _ := s.settingService.GetSubURI()
 	configuredSubURI, _ := s.settingService.GetSubURI()
 	configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
 	configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
+	configuredSubClashURI, _ := s.settingService.GetSubClashURI()
 
 
-	// Determine base scheme and host (cached to avoid duplicate calls)
 	var baseScheme, baseHostWithPort string
 	var baseScheme, baseHostWithPort string
-	if configuredSubURI == "" || configuredSubJsonURI == "" {
+	if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
 		baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
 		baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
 	}
 	}
 
 
-	// Build subscription URL
 	subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
 	subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
-
-	// Build JSON subscription URL
 	subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
 	subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
+	subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
 
 
-	return subURL, subJsonURL
+	return subURL, subJsonURL, subClashURL
 }
 }
 
 
 // getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
 // getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
@@ -1149,7 +1146,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
 
 
 // BuildPageData parses header and prepares the template view model.
 // BuildPageData parses header and prepares the template view model.
 // BuildPageData constructs page data for rendering the subscription information page.
 // BuildPageData constructs page data for rendering the subscription information page.
-func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
+func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
 	download := common.FormatTraffic(traffic.Down)
 	download := common.FormatTraffic(traffic.Down)
 	upload := common.FormatTraffic(traffic.Up)
 	upload := common.FormatTraffic(traffic.Up)
 	total := "∞"
 	total := "∞"
@@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		TotalByte:    traffic.Total,
 		TotalByte:    traffic.Total,
 		SubUrl:       subURL,
 		SubUrl:       subURL,
 		SubJsonUrl:   subJsonURL,
 		SubJsonUrl:   subJsonURL,
+		SubClashUrl:  subClashURL,
 		Result:       subs,
 		Result:       subs,
 	}
 	}
 }
 }

+ 1 - 1
update.sh

@@ -630,7 +630,7 @@ prompt_and_setup_ssl() {
 
 
         # 3.1 Request Domain to compose Panel URL later
         # 3.1 Request Domain to compose Panel URL later
         read -rp "Please enter domain name certificate issued for: " custom_domain
         read -rp "Please enter domain name certificate issued for: " custom_domain
-        custom_domain="${custom_domain// /}" # Убираем пробелы
+        custom_domain="${custom_domain// /}" # Remove spaces
 
 
         # 3.2 Loop for Certificate Path
         # 3.2 Loop for Certificate Path
         while true; do
         while true; do

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
web/assets/css/custom.min.css


+ 24 - 1
web/assets/js/model/dbinbound.js

@@ -90,7 +90,16 @@ class DBInbound {
         return this.expiryTime < new Date().getTime();
         return this.expiryTime < new Date().getTime();
     }
     }
 
 
+    invalidateCache() {
+        this._cachedInbound = null;
+        this._clientStatsMap = null;
+    }
+
     toInbound() {
     toInbound() {
+        if (this._cachedInbound) {
+            return this._cachedInbound;
+        }
+
         let settings = {};
         let settings = {};
         if (!ObjectUtil.isEmpty(this.settings)) {
         if (!ObjectUtil.isEmpty(this.settings)) {
             settings = JSON.parse(this.settings);
             settings = JSON.parse(this.settings);
@@ -116,7 +125,21 @@ class DBInbound {
             sniffing: sniffing,
             sniffing: sniffing,
             clientStats: this.clientStats,
             clientStats: this.clientStats,
         };
         };
-        return Inbound.fromJson(config);
+        
+        this._cachedInbound = Inbound.fromJson(config);
+        return this._cachedInbound;
+    }
+
+    getClientStats(email) {
+        if (!this._clientStatsMap) {
+            this._clientStatsMap = new Map();
+            if (this.clientStats && Array.isArray(this.clientStats)) {
+                for (const stats of this.clientStats) {
+                    this._clientStatsMap.set(stats.email, stats);
+                }
+            }
+        }
+        return this._clientStatsMap.get(email);
     }
     }
 
 
     isMultiUser() {
     isMultiUser() {

+ 3 - 0
web/assets/js/model/setting.js

@@ -38,6 +38,8 @@ class AllSetting {
         this.subPort = 2096;
         this.subPort = 2096;
         this.subPath = "/sub/";
         this.subPath = "/sub/";
         this.subJsonPath = "/json/";
         this.subJsonPath = "/json/";
+        this.subClashEnable = true;
+        this.subClashPath = "/clash/";
         this.subDomain = "";
         this.subDomain = "";
         this.externalTrafficInformEnable = false;
         this.externalTrafficInformEnable = false;
         this.externalTrafficInformURI = "";
         this.externalTrafficInformURI = "";
@@ -48,6 +50,7 @@ class AllSetting {
         this.subShowInfo = true;
         this.subShowInfo = true;
         this.subURI = "";
         this.subURI = "";
         this.subJsonURI = "";
         this.subJsonURI = "";
+        this.subClashURI = "";
         this.subJsonFragment = "";
         this.subJsonFragment = "";
         this.subJsonNoises = "";
         this.subJsonNoises = "";
         this.subJsonMux = "";
         this.subJsonMux = "";

+ 7 - 0
web/assets/js/subscription.js

@@ -9,6 +9,7 @@
     sId: el.getAttribute('data-sid') || '',
     sId: el.getAttribute('data-sid') || '',
     subUrl: el.getAttribute('data-sub-url') || '',
     subUrl: el.getAttribute('data-sub-url') || '',
     subJsonUrl: el.getAttribute('data-subjson-url') || '',
     subJsonUrl: el.getAttribute('data-subjson-url') || '',
+    subClashUrl: el.getAttribute('data-subclash-url') || '',
     download: el.getAttribute('data-download') || '',
     download: el.getAttribute('data-download') || '',
     upload: el.getAttribute('data-upload') || '',
     upload: el.getAttribute('data-upload') || '',
     used: el.getAttribute('data-used') || '',
     used: el.getAttribute('data-used') || '',
@@ -98,13 +99,19 @@
       this.lang = LanguageManager.getLanguage();
       this.lang = LanguageManager.getLanguage();
       const tpl = document.getElementById('subscription-data');
       const tpl = document.getElementById('subscription-data');
       const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
       const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
+      const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
       if (sj) this.app.subJsonUrl = sj;
       if (sj) this.app.subJsonUrl = sj;
+      if (sc) this.app.subClashUrl = sc;
       drawQR(this.app.subUrl);
       drawQR(this.app.subUrl);
       try {
       try {
         const elJson = document.getElementById('qrcode-subjson');
         const elJson = document.getElementById('qrcode-subjson');
         if (elJson && this.app.subJsonUrl) {
         if (elJson && this.app.subJsonUrl) {
           new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
           new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
         }
         }
+        const elClash = document.getElementById('qrcode-subclash');
+        if (elClash && this.app.subClashUrl) {
+          new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
+        }
       } catch (e) { /* ignore */ }
       } catch (e) { /* ignore */ }
       this._onResize = () => { this.viewportWidth = window.innerWidth; };
       this._onResize = () => { this.viewportWidth = window.innerWidth; };
       window.addEventListener('resize', this._onResize);
       window.addEventListener('resize', this._onResize);

+ 5 - 3
web/controller/api.go

@@ -18,9 +18,9 @@ type APIController struct {
 }
 }
 
 
 // NewAPIController creates a new APIController instance and initializes its routes.
 // NewAPIController creates a new APIController instance and initializes its routes.
-func NewAPIController(g *gin.RouterGroup) *APIController {
+func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *APIController {
 	a := &APIController{}
 	a := &APIController{}
-	a.initRouter(g)
+	a.initRouter(g, customGeo)
 	return a
 	return a
 }
 }
 
 
@@ -35,7 +35,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 }
 }
 
 
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
-func (a *APIController) initRouter(g *gin.RouterGroup) {
+func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.CustomGeoService) {
 	// Main API group
 	// Main API group
 	api := g.Group("/panel/api")
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
 	api.Use(a.checkAPIAuth)
@@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 	server := api.Group("/server")
 	server := api.Group("/server")
 	a.serverController = NewServerController(server)
 	a.serverController = NewServerController(server)
 
 
+	NewCustomGeoController(api.Group("/custom-geo"), customGeo)
+
 	// Extra routes
 	// Extra routes
 	api.GET("/backuptotgbot", a.BackuptoTgbot)
 	api.GET("/backuptotgbot", a.BackuptoTgbot)
 }
 }

+ 180 - 0
web/controller/custom_geo.go

@@ -0,0 +1,180 @@
+package controller
+
+import (
+	"errors"
+	"net/http"
+	"strconv"
+
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/web/entity"
+	"github.com/mhsanaei/3x-ui/v2/web/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+type CustomGeoController struct {
+	BaseController
+	customGeoService *service.CustomGeoService
+}
+
+func NewCustomGeoController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *CustomGeoController {
+	a := &CustomGeoController{customGeoService: customGeo}
+	a.initRouter(g)
+	return a
+}
+
+func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
+	g.GET("/list", a.list)
+	g.GET("/aliases", a.aliases)
+	g.POST("/add", a.add)
+	g.POST("/update/:id", a.update)
+	g.POST("/delete/:id", a.delete)
+	g.POST("/download/:id", a.download)
+	g.POST("/update-all", a.updateAll)
+}
+
+func mapCustomGeoErr(c *gin.Context, err error) error {
+	if err == nil {
+		return nil
+	}
+	switch {
+	case errors.Is(err, service.ErrCustomGeoInvalidType):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
+	case errors.Is(err, service.ErrCustomGeoAliasRequired):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
+	case errors.Is(err, service.ErrCustomGeoAliasPattern):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
+	case errors.Is(err, service.ErrCustomGeoAliasReserved):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
+	case errors.Is(err, service.ErrCustomGeoURLRequired):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
+	case errors.Is(err, service.ErrCustomGeoInvalidURL):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
+	case errors.Is(err, service.ErrCustomGeoURLScheme):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
+	case errors.Is(err, service.ErrCustomGeoURLHost):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
+	case errors.Is(err, service.ErrCustomGeoDuplicateAlias):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
+	case errors.Is(err, service.ErrCustomGeoNotFound):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
+	case errors.Is(err, service.ErrCustomGeoDownload):
+		logger.Warning("custom geo download:", err)
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
+	case errors.Is(err, service.ErrCustomGeoSSRFBlocked):
+		logger.Warning("custom geo SSRF blocked:", err)
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
+	case errors.Is(err, service.ErrCustomGeoPathTraversal):
+		logger.Warning("custom geo path traversal blocked:", err)
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
+	default:
+		return err
+	}
+}
+
+func (a *CustomGeoController) list(c *gin.Context) {
+	list, err := a.customGeoService.GetAll()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
+		return
+	}
+	jsonObj(c, list, nil)
+}
+
+func (a *CustomGeoController) aliases(c *gin.Context) {
+	out, err := a.customGeoService.GetAliasesForUI()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
+		return
+	}
+	jsonObj(c, out, nil)
+}
+
+type customGeoForm struct {
+	Type  string `json:"type" form:"type"`
+	Alias string `json:"alias" form:"alias"`
+	Url   string `json:"url" form:"url"`
+}
+
+func (a *CustomGeoController) add(c *gin.Context) {
+	var form customGeoForm
+	if err := c.ShouldBind(&form); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
+		return
+	}
+	r := &model.CustomGeoResource{
+		Type:  form.Type,
+		Alias: form.Alias,
+		Url:   form.Url,
+	}
+	err := a.customGeoService.Create(r)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
+}
+
+func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
+		return 0, false
+	}
+	if id <= 0 {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
+		return 0, false
+	}
+	return id, true
+}
+
+func (a *CustomGeoController) update(c *gin.Context) {
+	id, ok := parseCustomGeoID(c, c.Param("id"))
+	if !ok {
+		return
+	}
+	var form customGeoForm
+	if bindErr := c.ShouldBind(&form); bindErr != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
+		return
+	}
+	r := &model.CustomGeoResource{
+		Type:  form.Type,
+		Alias: form.Alias,
+		Url:   form.Url,
+	}
+	err := a.customGeoService.Update(id, r)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
+}
+
+func (a *CustomGeoController) delete(c *gin.Context) {
+	id, ok := parseCustomGeoID(c, c.Param("id"))
+	if !ok {
+		return
+	}
+	name, err := a.customGeoService.Delete(id)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
+}
+
+func (a *CustomGeoController) download(c *gin.Context) {
+	id, ok := parseCustomGeoID(c, c.Param("id"))
+	if !ok {
+		return
+	}
+	name, err := a.customGeoService.TriggerUpdate(id)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
+}
+
+func (a *CustomGeoController) updateAll(c *gin.Context) {
+	res, err := a.customGeoService.TriggerUpdateAll()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
+		return
+	}
+	if len(res.Failed) > 0 {
+		c.JSON(http.StatusOK, entity.Msg{
+			Success: false,
+			Msg:     I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
+			Obj:     res,
+		})
+		return
+	}
+	jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
+}

+ 11 - 2
web/controller/util.go

@@ -50,8 +50,17 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 		}
 		}
 	} else {
 	} else {
 		m.Success = false
 		m.Success = false
-		m.Msg = msg + " (" + err.Error() + ")"
-		logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
+		errStr := err.Error()
+		if errStr != "" {
+			m.Msg = msg + " (" + errStr + ")"
+			logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
+		} else if msg != "" {
+			m.Msg = msg
+			logger.Warning(msg + " " + I18nWeb(c, "fail"))
+		} else {
+			m.Msg = I18nWeb(c, "somethingWentWrong")
+			logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
+		}
 	}
 	}
 	c.JSON(http.StatusOK, m)
 	c.JSON(http.StatusOK, m)
 }
 }

+ 4 - 2
web/controller/websocket.go

@@ -30,8 +30,10 @@ const (
 )
 )
 
 
 var upgrader = ws.Upgrader{
 var upgrader = ws.Upgrader{
-	ReadBufferSize:  4096, // Increased from 1024 for better performance
-	WriteBufferSize: 4096, // Increased from 1024 for better performance
+	ReadBufferSize:    32768,
+	WriteBufferSize:   32768,
+	EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
+
 	CheckOrigin: func(r *http.Request) bool {
 	CheckOrigin: func(r *http.Request) bool {
 		// Check origin for security
 		// Check origin for security
 		origin := r.Header.Get("Origin")
 		origin := r.Header.Get("Origin")

+ 28 - 0
web/controller/xray_setting.go

@@ -17,6 +17,7 @@ type XraySettingController struct {
 	OutboundService    service.OutboundService
 	OutboundService    service.OutboundService
 	XrayService        service.XrayService
 	XrayService        service.XrayService
 	WarpService        service.WarpService
 	WarpService        service.WarpService
+	NordService        service.NordService
 }
 }
 
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -35,6 +36,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 
 
 	g.POST("/", a.getXraySetting)
 	g.POST("/", a.getXraySetting)
 	g.POST("/warp/:action", a.warp)
 	g.POST("/warp/:action", a.warp)
+	g.POST("/nord/:action", a.nord)
 	g.POST("/update", a.updateSetting)
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
 	g.POST("/testOutbound", a.testOutbound)
@@ -123,6 +125,32 @@ func (a *XraySettingController) warp(c *gin.Context) {
 	jsonObj(c, resp, err)
 	jsonObj(c, resp, err)
 }
 }
 
 
+// nord handles NordVPN-related operations based on the action parameter.
+func (a *XraySettingController) nord(c *gin.Context) {
+	action := c.Param("action")
+	var resp string
+	var err error
+	switch action {
+	case "countries":
+		resp, err = a.NordService.GetCountries()
+	case "servers":
+		countryId := c.PostForm("countryId")
+		resp, err = a.NordService.GetServers(countryId)
+	case "reg":
+		token := c.PostForm("token")
+		resp, err = a.NordService.GetCredentials(token)
+	case "setKey":
+		key := c.PostForm("key")
+		resp, err = a.NordService.SetKey(key)
+	case "data":
+		resp, err = a.NordService.GetNordData()
+	case "del":
+		err = a.NordService.DelNordData()
+	}
+
+	jsonObj(c, resp, err)
+}
+
 // getOutboundsTraffic retrieves the traffic statistics for outbounds.
 // getOutboundsTraffic retrieves the traffic statistics for outbounds.
 func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
 func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
 	outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
 	outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()

+ 10 - 0
web/entity/entity.go

@@ -76,6 +76,9 @@ type AllSetting struct {
 	SubURI                      string `json:"subURI" form:"subURI"`                                           // Subscription server URI
 	SubURI                      string `json:"subURI" form:"subURI"`                                           // Subscription server URI
 	SubJsonPath                 string `json:"subJsonPath" form:"subJsonPath"`                                 // Path for JSON subscription endpoint
 	SubJsonPath                 string `json:"subJsonPath" form:"subJsonPath"`                                 // Path for JSON subscription endpoint
 	SubJsonURI                  string `json:"subJsonURI" form:"subJsonURI"`                                   // JSON subscription server URI
 	SubJsonURI                  string `json:"subJsonURI" form:"subJsonURI"`                                   // JSON subscription server URI
+	SubClashEnable              bool   `json:"subClashEnable" form:"subClashEnable"`                           // Enable Clash/Mihomo subscription endpoint
+	SubClashPath                string `json:"subClashPath" form:"subClashPath"`                               // Path for Clash/Mihomo subscription endpoint
+	SubClashURI                 string `json:"subClashURI" form:"subClashURI"`                                 // Clash/Mihomo subscription server URI
 	SubJsonFragment             string `json:"subJsonFragment" form:"subJsonFragment"`                         // JSON subscription fragment configuration
 	SubJsonFragment             string `json:"subJsonFragment" form:"subJsonFragment"`                         // JSON subscription fragment configuration
 	SubJsonNoises               string `json:"subJsonNoises" form:"subJsonNoises"`                             // JSON subscription noise configuration
 	SubJsonNoises               string `json:"subJsonNoises" form:"subJsonNoises"`                             // JSON subscription noise configuration
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
@@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
 		s.SubJsonPath += "/"
 		s.SubJsonPath += "/"
 	}
 	}
 
 
+	if !strings.HasPrefix(s.SubClashPath, "/") {
+		s.SubClashPath = "/" + s.SubClashPath
+	}
+	if !strings.HasSuffix(s.SubClashPath, "/") {
+		s.SubClashPath += "/"
+	}
+
 	_, err := time.LoadLocation(s.TimeLocation)
 	_, err := time.LoadLocation(s.TimeLocation)
 	if err != nil {
 	if err != nil {
 		return common.NewError("time location not exist:", s.TimeLocation)
 		return common.NewError("time location not exist:", s.TimeLocation)

+ 2 - 0
web/html/form/inbound.html

@@ -73,6 +73,8 @@
             :dropdown-class-name="themeSwitcher.currentTheme">
             :dropdown-class-name="themeSwitcher.currentTheme">
             <a-select-option value="never">{{ i18n
             <a-select-option value="never">{{ i18n
                 "pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
                 "pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
+            <a-select-option value="hourly">{{ i18n
+                "pages.inbounds.periodicTrafficReset.hourly" }}</a-select-option>
             <a-select-option value="daily">{{ i18n
             <a-select-option value="daily">{{ i18n
                 "pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
                 "pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
             <a-select-option value="weekly">{{ i18n
             <a-select-option value="weekly">{{ i18n

+ 3 - 0
web/html/form/stream/stream_xhttp.html

@@ -70,6 +70,8 @@
                 <a-select-option
                 <a-select-option
                     value="queryInHeader">queryInHeader</a-select-option>
                     value="queryInHeader">queryInHeader</a-select-option>
                 <a-select-option value="header">header</a-select-option>
                 <a-select-option value="header">header</a-select-option>
+                <a-select-option value="cookie">cookie</a-select-option>
+                <a-select-option value="query">query</a-select-option>
             </a-select>
             </a-select>
         </a-form-item>
         </a-form-item>
         <a-form-item label="Padding Method">
         <a-form-item label="Padding Method">
@@ -127,6 +129,7 @@
             <a-select-option value>Default (body)</a-select-option>
             <a-select-option value>Default (body)</a-select-option>
             <a-select-option value="body">body</a-select-option>
             <a-select-option value="body">body</a-select-option>
             <a-select-option value="header">header</a-select-option>
             <a-select-option value="header">header</a-select-option>
+            <a-select-option value="cookie">cookie</a-select-option>
             <a-select-option value="query">query</a-select-option>
             <a-select-option value="query">query</a-select-option>
         </a-select>
         </a-select>
     </a-form-item>
     </a-form-item>

+ 115 - 37
web/html/inbounds.html

@@ -6,7 +6,7 @@
   <a-sidebar></a-sidebar>
   <a-sidebar></a-sidebar>
   <a-layout id="content-layout">
   <a-layout id="content-layout">
     <a-layout-content>
     <a-layout-content>
-      <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
+      <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
         <transition name="list" appear>
         <transition name="list" appear>
           <a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
           <a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
             message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
             message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
@@ -14,10 +14,7 @@
         </transition>
         </transition>
         <transition name="list" appear>
         <transition name="list" appear>
           <a-row v-if="!loadingStates.fetched">
           <a-row v-if="!loadingStates.fetched">
-            <a-card
-              :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
-              <a-spin tip='{{ i18n "loading" }}'></a-spin>
-            </a-card>
+            <div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
           </a-row>
           </a-row>
           <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
           <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
             <a-col>
             <a-col>
@@ -1101,7 +1098,10 @@
         }
         }
         data.sniffing = inbound.sniffing.toString();
         data.sniffing = inbound.sniffing.toString();
 
 
-        await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
+        const formData = new FormData();
+        Object.keys(data).forEach(key => formData.append(key, data[key]));
+
+        await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, formData, inModal);
       },
       },
       openAddClient(dbInboundId) {
       openAddClient(dbInboundId) {
         dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
         dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
@@ -1291,9 +1291,36 @@
         infoModal.show(newDbInbound, index);
         infoModal.show(newDbInbound, index);
       },
       },
       switchEnable(dbInboundId, state) {
       switchEnable(dbInboundId, state) {
-        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        if (!dbInbound) return;
         dbInbound.enable = state;
         dbInbound.enable = state;
-        this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
+        let inbound = dbInbound.toInbound();
+        const data = {
+          up: dbInbound.up,
+          down: dbInbound.down,
+          total: dbInbound.total,
+          remark: dbInbound.remark,
+          enable: dbInbound.enable,
+          expiryTime: dbInbound.expiryTime,
+          trafficReset: dbInbound.trafficReset,
+          lastTrafficResetTime: dbInbound.lastTrafficResetTime,
+
+          listen: inbound.listen,
+          port: inbound.port,
+          protocol: inbound.protocol,
+          settings: inbound.settings.toString(),
+        };
+        if (inbound.canEnableStream()) {
+          data.streamSettings = inbound.stream.toString();
+        } else if (inbound.stream?.sockopt) {
+          data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
+        }
+        data.sniffing = inbound.sniffing.toString();
+
+        const formData = new FormData();
+        Object.keys(data).forEach(key => formData.append(key, data[key]));
+
+        this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData);
       },
       },
       async switchEnableClient(dbInboundId, client) {
       async switchEnableClient(dbInboundId, client) {
         this.loading()
         this.loading()
@@ -1367,42 +1394,54 @@
       isExpiry(dbInbound, index) {
       isExpiry(dbInbound, index) {
         return dbInbound.toInbound().isExpiry(index);
         return dbInbound.toInbound().isExpiry(index);
       },
       },
+      getClientStats(dbInbound, email) {
+        if (!dbInbound) return null;
+        if (!dbInbound._clientStatsMap) {
+            dbInbound._clientStatsMap = new Map();
+            if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
+                for (const stats of dbInbound.clientStats) {
+                    dbInbound._clientStatsMap.set(stats.email, stats);
+                }
+            }
+        }
+        return dbInbound._clientStatsMap.get(email);
+      },
       getUpStats(dbInbound, email) {
       getUpStats(dbInbound, email) {
-        if (email.length == 0) return 0;
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return 0;
+        let clientStats = this.getClientStats(dbInbound, email);
         return clientStats ? clientStats.up : 0;
         return clientStats ? clientStats.up : 0;
       },
       },
       getDownStats(dbInbound, email) {
       getDownStats(dbInbound, email) {
-        if (email.length == 0) return 0;
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return 0;
+        let clientStats = this.getClientStats(dbInbound, email);
         return clientStats ? clientStats.down : 0;
         return clientStats ? clientStats.down : 0;
       },
       },
       getSumStats(dbInbound, email) {
       getSumStats(dbInbound, email) {
-        if (email.length == 0) return 0;
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return 0;
+        let clientStats = this.getClientStats(dbInbound, email);
         return clientStats ? clientStats.up + clientStats.down : 0;
         return clientStats ? clientStats.up + clientStats.down : 0;
       },
       },
       getAllTimeClient(dbInbound, email) {
       getAllTimeClient(dbInbound, email) {
-        if (email.length == 0) return 0;
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return 0;
+        let clientStats = this.getClientStats(dbInbound, email);
         if (!clientStats) return 0;
         if (!clientStats) return 0;
         return clientStats.allTime || (clientStats.up + clientStats.down);
         return clientStats.allTime || (clientStats.up + clientStats.down);
       },
       },
       getRemStats(dbInbound, email) {
       getRemStats(dbInbound, email) {
-        if (email.length == 0) return 0;
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return 0;
+        let clientStats = this.getClientStats(dbInbound, email);
         if (!clientStats) return 0;
         if (!clientStats) return 0;
-        remained = clientStats.total - (clientStats.up + clientStats.down);
+        let remained = clientStats.total - (clientStats.up + clientStats.down);
         return remained > 0 ? remained : 0;
         return remained > 0 ? remained : 0;
       },
       },
       clientStatsColor(dbInbound, email) {
       clientStatsColor(dbInbound, email) {
-        if (email.length == 0) return ColorUtils.clientUsageColor();
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return ColorUtils.clientUsageColor();
+        let clientStats = this.getClientStats(dbInbound, email);
         return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
         return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
       },
       },
       statsProgress(dbInbound, email) {
       statsProgress(dbInbound, email) {
-        if (email.length == 0) return 100;
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return 100;
+        let clientStats = this.getClientStats(dbInbound, email);
         if (!clientStats) return 0;
         if (!clientStats) return 0;
         if (clientStats.total == 0) return 100;
         if (clientStats.total == 0) return 100;
         return 100 * (clientStats.down + clientStats.up) / clientStats.total;
         return 100 * (clientStats.down + clientStats.up) / clientStats.total;
@@ -1415,11 +1454,11 @@
         return 100 * (1 - (remainedSeconds / resetSeconds));
         return 100 * (1 - (remainedSeconds / resetSeconds));
       },
       },
       statsExpColor(dbInbound, email) {
       statsExpColor(dbInbound, email) {
-        if (email.length == 0) return '#7a316f';
-        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!email || email.length == 0) return '#7a316f';
+        let clientStats = this.getClientStats(dbInbound, email);
         if (!clientStats) return '#7a316f';
         if (!clientStats) return '#7a316f';
-        statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
-        expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
+        let statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
+        let expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
         switch (true) {
         switch (true) {
           case statsColor == "red" || expColor == "red":
           case statsColor == "red" || expColor == "red":
             return "#cf3c3c"; // Red
             return "#cf3c3c"; // Red
@@ -1432,12 +1471,12 @@
         }
         }
       },
       },
       isClientEnabled(dbInbound, email) {
       isClientEnabled(dbInbound, email) {
-        clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
+        let clientStats = dbInbound ? this.getClientStats(dbInbound, email) : null;
         return clientStats ? clientStats['enable'] : true;
         return clientStats ? clientStats['enable'] : true;
       },
       },
       isClientDepleted(dbInbound, email) {
       isClientDepleted(dbInbound, email) {
-        if (!email || !dbInbound || !dbInbound.clientStats) return false;
-        const stats = dbInbound.clientStats.find(s => s.email === email);
+        if (!email || !dbInbound) return false;
+        const stats = this.getClientStats(dbInbound, email);
         if (!stats) return false;
         if (!stats) return false;
         const total = stats.total ?? 0;
         const total = stats.total ?? 0;
         const used = (stats.up ?? 0) + (stats.down ?? 0);
         const used = (stats.up ?? 0) + (stats.down ?? 0);
@@ -1557,12 +1596,18 @@
       pagination(obj) {
       pagination(obj) {
         if (this.pageSize > 0 && obj.length > this.pageSize) {
         if (this.pageSize > 0 && obj.length > this.pageSize) {
           // Set page options based on object size
           // Set page options based on object size
-          sizeOptions = [];
-          for (i = this.pageSize; i <= obj.length; i = i + this.pageSize) {
-            sizeOptions.push(i.toString());
+          let sizeOptions = [this.pageSize.toString()];
+          const increments = [2, 5, 10, 20];
+          for (const m of increments) {
+            const val = this.pageSize * m;
+            if (val < obj.length && val <= 1000) {
+              sizeOptions.push(val.toString());
+            }
           }
           }
           // Add option to see all in one page
           // Add option to see all in one page
-          sizeOptions.push(i.toString());
+          if (!sizeOptions.includes(obj.length.toString())) {
+             sizeOptions.push(obj.length.toString());
+          }
 
 
           p = {
           p = {
             showSizeChanger: true,
             showSizeChanger: true,
@@ -1605,11 +1650,25 @@
           }
           }
         });
         });
 
 
+        // Listen for invalidate signals (sent when payload is too large for WebSocket)
+        // The server sends a lightweight notification and we re-fetch via REST API
+        let invalidateTimer = null;
+        window.wsClient.on('invalidate', (payload) => {
+          if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
+            // Debounce to avoid flooding the REST API with multiple invalidate signals
+            if (invalidateTimer) clearTimeout(invalidateTimer);
+            invalidateTimer = setTimeout(() => {
+              invalidateTimer = null;
+              this.getDBInbounds();
+            }, 1000);
+          }
+        });
+
         // Listen for traffic updates
         // Listen for traffic updates
         window.wsClient.on('traffic', (payload) => {
         window.wsClient.on('traffic', (payload) => {
           // Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
           // Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
           // because clientTraffics contains delta/incremental values, not total accumulated values.
           // because clientTraffics contains delta/incremental values, not total accumulated values.
-          // Total traffic is updated via the 'inbounds' event which contains accumulated values from database.
+          // Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels).
           
           
           // Update online clients list in real-time
           // Update online clients list in real-time
           if (payload && Array.isArray(payload.onlineClients)) {
           if (payload && Array.isArray(payload.onlineClients)) {
@@ -1627,22 +1686,27 @@
             this.onlineClients = nextOnlineClients;
             this.onlineClients = nextOnlineClients;
             if (onlineChanged) {
             if (onlineChanged) {
               // Recalculate client counts to update online status
               // Recalculate client counts to update online status
+              // Use $set for Vue 2 reactivity — direct array index assignment is not reactive
               this.dbInbounds.forEach(dbInbound => {
               this.dbInbounds.forEach(dbInbound => {
                 const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
                 const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
                 if (inbound && this.clientCount[dbInbound.id]) {
                 if (inbound && this.clientCount[dbInbound.id]) {
-                  this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
+                  this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
                 }
                 }
               });
               });
 
 
+              // Always trigger UI refresh — not just when filter is enabled
               if (this.enableFilter) {
               if (this.enableFilter) {
                 this.filterInbounds();
                 this.filterInbounds();
+              } else {
+                this.searchInbounds(this.searchKey);
               }
               }
             }
             }
           }
           }
           
           
           // Update last online map in real-time
           // Update last online map in real-time
+          // Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients
           if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
           if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
-            this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
+            this.lastOnlineMap = payload.lastOnlineMap;
           }
           }
         });
         });
 
 
@@ -1697,4 +1761,18 @@
     },
     },
   });
   });
 </script>
 </script>
+<style>
+  #content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
+    position: fixed !important;
+    top: 50vh !important;
+    left: calc(50vw + 100px) !important;
+    transform: translate(-50%, -50%);
+    z-index: 99999 !important;
+  }
+  @media (max-width: 768px) {
+    #content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
+      left: 50vw !important;
+    }
+  }
+</style>
 {{ template "page/body_end" .}}
 {{ template "page/body_end" .}}

+ 222 - 6
web/html/index.html

@@ -2,11 +2,25 @@
 {{ template "page/head_end" .}}
 {{ template "page/head_end" .}}
 
 
 {{ template "page/body_start" .}}
 {{ template "page/body_start" .}}
+<style>
+  body.dark .custom-geo-section code.custom-geo-ext-code {
+    color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
+    background: var(--dark-color-surface-200, #222d42);
+    border: 1px solid var(--dark-color-stroke, #2c3950);
+    padding: 2px 6px;
+    border-radius: 3px;
+  }
+  html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
+    color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
+    background: var(--dark-color-surface-700, #111929);
+    border-color: var(--dark-color-stroke, #2c3950);
+  }
+</style>
 <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
 <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
   <a-sidebar></a-sidebar>
   <a-sidebar></a-sidebar>
   <a-layout id="content-layout">
   <a-layout id="content-layout">
     <a-layout-content>
     <a-layout-content>
-      <a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
+      <a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip" size="large">
         <transition name="list" appear>
         <transition name="list" appear>
           <a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
           <a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
             message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
             message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
@@ -15,9 +29,7 @@
         <transition name="list" appear>
         <transition name="list" appear>
           <template>
           <template>
             <a-row v-if="!loadingStates.fetched">
             <a-row v-if="!loadingStates.fetched">
-              <a-card class="card-placeholder text-center">
-                <a-spin tip='{{ i18n "loading" }}'></a-spin>
-              </a-card>
+              <div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
             </a-row>
             </a-row>
             <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
             <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
               <a-col>
               <a-col>
@@ -107,7 +119,7 @@
                           </a-row>
                           </a-row>
                         </span>
                         </span>
                         <template slot="content">
                         <template slot="content">
-                          <span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
+                          <span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
                         </template>
                         </template>
                         <a-badge :text="status.xray.stateMsg" :color="status.xray.color"
                         <a-badge :text="status.xray.stateMsg" :color="status.xray.color"
                           :class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
                           :class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
@@ -115,7 +127,7 @@
                     </template>
                     </template>
                   </template>
                   </template>
                   <template #actions>
                   <template #actions>
-                    <a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
+                    <a-space v-if="ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
                       <a-icon type="bars"></a-icon>
                       <a-icon type="bars"></a-icon>
                       <span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
                       <span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
                     </a-space>
                     </a-space>
@@ -330,8 +342,65 @@
         <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
         <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
             "pages.index.geofilesUpdateAll" }}</a-button></div>
             "pages.index.geofilesUpdateAll" }}</a-button></div>
       </a-collapse-panel>
       </a-collapse-panel>
+      <a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
+        <div class="custom-geo-section">
+        <a-alert type="info" show-icon class="mb-10"
+          message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
+        <div class="mb-10">
+          <a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
+            {{ i18n "pages.index.customGeoAdd" }}
+          </a-button>
+          <a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
+            "pages.index.geofilesUpdateAll" }}</a-button>
+        </div>
+        <a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
+          :loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
+          <template slot="extDat" slot-scope="text, record">
+            <code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
+          </template>
+          <template slot="lastUpdatedAt" slot-scope="text, record">
+            <span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
+            <span v-else>—</span>
+          </template>
+          <template slot="action" slot-scope="text, record">
+            <a-space size="small">
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
+                <a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
+              </a-tooltip>
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
+                <a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
+              </a-tooltip>
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
+                <a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
+              </a-tooltip>
+            </a-space>
+          </template>
+        </a-table>
+        </div>
+      </a-collapse-panel>
     </a-collapse>
     </a-collapse>
   </a-modal>
   </a-modal>
+  <a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
+    :confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
+    :class="themeSwitcher.currentTheme">
+    <a-form layout="vertical">
+      <a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
+        <a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
+          <a-select-option value="geosite">geosite</a-select-option>
+          <a-select-option value="geoip">geoip</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
+        <a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
+      </a-form-item>
+      <a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
+        <a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
+      </a-form-item>
+    </a-form>
+  </a-modal>
   <a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
   <a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
     :class="themeSwitcher.currentTheme" width="800px" footer="">
     :class="themeSwitcher.currentTheme" width="800px" footer="">
     <template slot="title">
     <template slot="title">
@@ -872,6 +941,12 @@
     },
     },
   };
   };
 
 
+  const customGeoColumns = [
+    { title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
+    { title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
+    { title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
+  ];
+
   const app = new Vue({
   const app = new Vue({
     delimiters: ['[[', ']]'],
     delimiters: ['[[', ']]'],
     el: '#app',
     el: '#app',
@@ -895,6 +970,25 @@
       showAlert: false,
       showAlert: false,
       showIp: false,
       showIp: false,
       ipLimitEnable: false,
       ipLimitEnable: false,
+      customGeoColumns,
+      customGeoList: [],
+      customGeoLoading: false,
+      customGeoUpdatingAll: false,
+      customGeoActionId: null,
+      customGeoModal: {
+        visible: false,
+        editId: null,
+        saving: false,
+        form: {
+          type: 'geosite',
+          alias: '',
+          url: '',
+        },
+      },
+      customGeoValidation: {
+        alias: '{{ i18n "pages.index.customGeoValidationAlias" }}',
+        url: '{{ i18n "pages.index.customGeoValidationUrl" }}',
+      },
     },
     },
     methods: {
     methods: {
       loading(spinning, tip = '{{ i18n "loading"}}') {
       loading(spinning, tip = '{{ i18n "loading"}}') {
@@ -963,6 +1057,128 @@
           return;
           return;
         }
         }
         versionModal.show(msg.obj);
         versionModal.show(msg.obj);
+        this.loadCustomGeo();
+      },
+      customGeoFormatTime(ts) {
+        if (!ts) return '';
+        return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
+      },
+      customGeoExtDisplay(record) {
+        const fn = record.type === 'geoip'
+          ? `geoip_${record.alias}.dat`
+          : `geosite_${record.alias}.dat`;
+        return `ext:${fn}:tag`;
+      },
+      async loadCustomGeo() {
+        this.customGeoLoading = true;
+        try {
+          const msg = await HttpUtil.get('/panel/api/custom-geo/list');
+          if (msg.success && Array.isArray(msg.obj)) {
+            this.customGeoList = msg.obj;
+          }
+        } finally {
+          this.customGeoLoading = false;
+        }
+      },
+      openCustomGeoModal(record) {
+        if (record) {
+          this.customGeoModal.editId = record.id;
+          this.customGeoModal.form = {
+            type: record.type,
+            alias: record.alias,
+            url: record.url,
+          };
+        } else {
+          this.customGeoModal.editId = null;
+          this.customGeoModal.form = {
+            type: 'geosite',
+            alias: '',
+            url: '',
+          };
+        }
+        this.customGeoModal.visible = true;
+      },
+      validateCustomGeoForm() {
+        const f = this.customGeoModal.form;
+        const re = /^[a-z0-9_-]+$/;
+        if (!re.test(f.alias || '')) {
+          this.$message.error(this.customGeoValidation.alias);
+          return false;
+        }
+        const u = (f.url || '').trim();
+        if (!/^https?:\/\//i.test(u)) {
+          this.$message.error(this.customGeoValidation.url);
+          return false;
+        }
+        try {
+          const parsed = new URL(u);
+          if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+            this.$message.error(this.customGeoValidation.url);
+            return false;
+          }
+        } catch (e) {
+          this.$message.error(this.customGeoValidation.url);
+          return false;
+        }
+        return true;
+      },
+      async submitCustomGeo() {
+        if (!this.validateCustomGeoForm()) {
+          return;
+        }
+        const f = this.customGeoModal.form;
+        this.customGeoModal.saving = true;
+        try {
+          let msg;
+          if (this.customGeoModal.editId) {
+            msg = await HttpUtil.post(`/panel/api/custom-geo/update/${this.customGeoModal.editId}`, f);
+          } else {
+            msg = await HttpUtil.post('/panel/api/custom-geo/add', f);
+          }
+          if (msg && msg.success) {
+            this.customGeoModal.visible = false;
+            await this.loadCustomGeo();
+          }
+        } finally {
+          this.customGeoModal.saving = false;
+        }
+      },
+      confirmDeleteCustomGeo(record) {
+        this.$confirm({
+          title: '{{ i18n "pages.index.customGeoDelete" }}',
+          content: '{{ i18n "pages.index.customGeoDeleteConfirm" }}',
+          okText: '{{ i18n "confirm"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          class: themeSwitcher.currentTheme,
+          onOk: async () => {
+            const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
+            if (msg.success) {
+              await this.loadCustomGeo();
+            }
+          },
+        });
+      },
+      async downloadCustomGeo(id) {
+        this.customGeoActionId = id;
+        try {
+          const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
+          if (msg.success) {
+            await this.loadCustomGeo();
+          }
+        } finally {
+          this.customGeoActionId = null;
+        }
+      },
+      async updateAllCustomGeo() {
+        this.customGeoUpdatingAll = true;
+        try {
+          const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
+          if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
+            await this.loadCustomGeo();
+          }
+        } finally {
+          this.customGeoUpdatingAll = false;
+        }
       },
       },
       switchV2rayVersion(version) {
       switchV2rayVersion(version) {
         this.$confirm({
         this.$confirm({

+ 3 - 3
web/html/modals/client_bulk_modal.html

@@ -26,7 +26,7 @@
             <a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
             <a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
         </a-form-item>
         </a-form-item>
         <a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
         <a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
-            <a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="100"></a-input-number>
+            <a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="500"></a-input-number>
         </a-form-item>
         </a-form-item>
         <a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
         <a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
             <a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
             <a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
@@ -204,7 +204,7 @@
             this.security = "auto";
             this.security = "auto";
             this.flow = "";
             this.flow = "";
             this.dbInbound = new DBInbound(dbInbound);
             this.dbInbound = new DBInbound(dbInbound);
-            this.inbound = dbInbound.toInbound();
+            this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
             this.delayedStart = false;
             this.delayedStart = false;
             this.reset = 0;
             this.reset = 0;
         },
         },
@@ -247,4 +247,4 @@
     });
     });
 
 
 </script>
 </script>
-{{end}}
+{{end}}

+ 4 - 4
web/html/modals/client_modal.html

@@ -37,7 +37,7 @@
             this.okText = okText;
             this.okText = okText;
             this.isEdit = isEdit;
             this.isEdit = isEdit;
             this.dbInbound = new DBInbound(dbInbound);
             this.dbInbound = new DBInbound(dbInbound);
-            this.inbound = dbInbound.toInbound();
+            this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
             this.clients = this.inbound.clients;
             this.clients = this.inbound.clients;
             this.index = index === null ? this.clients.length : index;
             this.index = index === null ? this.clients.length : index;
             this.delayedStart = false;
             this.delayedStart = false;
@@ -98,9 +98,9 @@
                 return app.datepicker;
                 return app.datepicker;
             },
             },
             get isTrafficExhausted() {
             get isTrafficExhausted() {
-                if (!clientStats) return false
-                if (clientStats.total <= 0) return false
-                if (clientStats.up + clientStats.down < clientStats.total) return false
+                if (!this.clientStats) return false
+                if (this.clientStats.total <= 0) return false
+                if (this.clientStats.up + this.clientStats.down < this.clientStats.total) return false
                 return true
                 return true
             },
             },
             get isExpiry() {
             get isExpiry() {

+ 306 - 0
web/html/modals/nord_modal.html

@@ -0,0 +1,306 @@
+{{define "modals/nordModal"}}
+<a-modal id="nord-modal" v-model="nordModal.visible" title="NordVPN NordLynx"
+         :confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true"
+         :footer="null" :class="themeSwitcher.currentTheme">
+    <template v-if="nordModal.nordData == null">
+        <a-tabs default-active-key="token" :class="themeSwitcher.currentTheme">
+            <a-tab-pane key="token" tab='{{ i18n "pages.xray.outbound.accessToken" }}'>
+                <a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
+                    <a-form-item label='{{ i18n "pages.xray.outbound.accessToken" }}'>
+                        <a-input v-model="nordModal.token" placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
+                        <div :style="{ marginTop: '10px' }">
+                            <a-button type="primary" icon="login" @click="login()" :loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
+                        </div>
+                    </a-form-item>
+                </a-form>
+            </a-tab-pane>
+            <a-tab-pane key="key" tab='{{ i18n "pages.xray.outbound.privateKey" }}'>
+                <a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
+                    <a-form-item label='{{ i18n "pages.xray.outbound.privateKey" }}'>
+                        <a-input v-model="nordModal.manualKey" placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
+                        <div :style="{ marginTop: '10px' }">
+                            <a-button type="primary" icon="save" @click="saveKey()" :loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
+                        </div>
+                    </a-form-item>
+                </a-form>
+            </a-tab-pane>
+        </a-tabs>
+    </template>
+    <template v-else>
+        <table :style="{ margin: '5px 0', width: '100%' }">
+            <tr class="client-table-odd-row" v-if="nordModal.nordData.token">
+                <td>{{ i18n "pages.xray.outbound.accessToken" }}</td>
+                <td>[[ nordModal.nordData.token ]]</td>
+            </tr>
+            <tr>
+                <td>{{ i18n "pages.xray.outbound.privateKey" }}</td>
+                <td>[[ nordModal.nordData.private_key ]]</td>
+            </tr>
+        </table>
+        <a-button @click="logout" :loading="nordModal.confirmLoading" type="danger">{{ i18n "logout" }}</a-button>
+        <a-divider :style="{ margin: '0' }">{{ i18n "pages.xray.outbound.settings" }}</a-divider>
+        <a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '10px' }">
+            <a-form-item label='{{ i18n "pages.xray.outbound.country" }}'>
+                <a-select v-model="nordModal.countryId" @change="fetchServers" show-search option-filter-prop="label">
+                    <a-select-option v-for="c in nordModal.countries" :key="c.id" :value="c.id" :label="c.name">
+                        [[ c.name ]] ([[ c.code ]])
+                    </a-select-option>
+                </a-select>
+            </a-form-item>
+            <a-form-item label='{{ i18n "pages.xray.outbound.city" }}' v-if="nordModal.cities.length > 0">
+                <a-select v-model="nordModal.cityId" @change="onCityChange" show-search option-filter-prop="label">
+                    <a-select-option :key="0" :value="null" label='{{ i18n "pages.xray.outbound.allCities" }}'>
+                        {{ i18n "pages.xray.outbound.allCities" }}
+                    </a-select-option>
+                    <a-select-option v-for="c in nordModal.cities" :key="c.id" :value="c.id" :label="c.name">
+                        [[ c.name ]]
+                    </a-select-option>
+                </a-select>
+            </a-form-item>
+            <a-form-item label='{{ i18n "pages.xray.outbound.server" }}' v-if="filteredServers.length > 0">
+                <a-select v-model="nordModal.serverId">
+                    <a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
+                        [[ s.cityName ]] - [[ s.name ]] ({{ i18n "pages.xray.outbound.load" }}: [[ s.load ]]%)
+                    </a-select-option>
+                </a-select>
+            </a-form-item>
+        </a-form>
+        <a-divider :style="{ margin: '10px 0' }">{{ i18n "pages.xray.outbound.outboundStatus" }}</a-divider>
+        <a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
+            <template v-if="nordOutboundIndex>=0">
+                <a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
+                <a-button @click="resetOutbound" :loading="nordModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
+            </template>
+            <template v-else>
+                <a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
+                <a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
+            </template>
+        </a-form>
+    </template>
+</a-modal>
+
+<script>
+    const nordModal = {
+        visible: false,
+        confirmLoading: false,
+        nordData: null,
+        token: '',
+        manualKey: '',
+        countries: [],
+        countryId: null,
+        cities: [],
+        cityId: null,
+        servers: [],
+        serverId: null,
+        show() {
+            this.visible = true;
+            this.getData();
+        },
+        close() {
+            this.visible = false;
+        },
+        loading(loading = true) {
+            this.confirmLoading = loading;
+        },
+        async getData() {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/data');
+            if (msg.success) {
+                this.nordData = msg.obj ? JSON.parse(msg.obj) : null;
+                if (this.nordData) {
+                    await this.fetchCountries();
+                }
+            }
+            this.loading(false);
+        },
+        async login() {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: this.token });
+            if (msg.success) {
+                this.nordData = JSON.parse(msg.obj);
+                await this.fetchCountries();
+            }
+            this.loading(false);
+        },
+        async saveKey() {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: this.manualKey });
+            if (msg.success) {
+                this.nordData = JSON.parse(msg.obj);
+                await this.fetchCountries();
+            }
+            this.loading(false);
+        },
+        async logout(index) {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/del');
+            if (msg.success) {
+                this.delOutbound(index);
+                this.delRouting();
+                this.nordData = null;
+                this.token = '';
+                this.manualKey = '';
+                this.countries = [];
+                this.cities = [];
+                this.servers = [];
+                this.countryId = null;
+                this.cityId = null;
+            }
+            this.loading(false);
+        },
+        async fetchCountries() {
+            const msg = await HttpUtil.post('/panel/xray/nord/countries');
+            if (msg.success) {
+                this.countries = JSON.parse(msg.obj);
+            }
+        },
+        async fetchServers() {
+            this.loading(true);
+            this.servers = [];
+            this.cities = [];
+            this.serverId = null;
+            this.cityId = null;
+            const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: this.countryId });
+            if (msg.success) {
+                const data = JSON.parse(msg.obj);
+                const locations = data.locations || [];
+                const locToCity = {};
+                const citiesMap = new Map();
+                locations.forEach(loc => {
+                    if (loc.country && loc.country.city) {
+                        citiesMap.set(loc.country.city.id, loc.country.city);
+                        locToCity[loc.id] = loc.country.city;
+                    }
+                });
+                this.cities = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
+                
+                this.servers = (data.servers || []).map(s => {
+                    const firstLocId = (s.location_ids || [])[0];
+                    const city = locToCity[firstLocId];
+                    s.cityId = city ? city.id : null;
+                    s.cityName = city ? city.name : 'Unknown';
+                    return s;
+                }).sort((a, b) => a.load - b.load);
+
+                if (this.servers.length > 0) {
+                    this.serverId = this.servers[0].id;
+                }
+
+                if (this.servers.length === 0) {
+                    app.$message.warning('No servers found for the selected country');
+                }
+            }
+            this.loading(false);
+        },
+        addOutbound() {
+            const server = this.servers.find(s => s.id === this.serverId);
+            if (!server) return;
+            
+            const tech = server.technologies.find(t => t.id === 35);
+            const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
+
+            const outbound = {
+                tag: `nord-${server.hostname}`,
+                protocol: 'wireguard',
+                settings: {
+                    secretKey: this.nordData.private_key,
+                    address: ['10.5.0.2/32'],
+                    peers: [{
+                        publicKey: publicKey,
+                        endpoint: server.station + ':51820'
+                    }],
+                    noKernelTun: false
+                }
+            };
+
+            app.templateSettings.outbounds.push(outbound);
+            app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
+            this.close();
+            app.$message.success('NordVPN outbound added');
+        },
+        resetOutbound(index) {
+            const server = this.servers.find(s => s.id === this.serverId);
+            if (!server || index === -1) return;
+            
+            const tech = server.technologies.find(t => t.id === 35);
+            const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
+
+            const oldTag = app.templateSettings.outbounds[index].tag;
+            const newTag = `nord-${server.hostname}`;
+
+            const outbound = {
+                tag: newTag,
+                protocol: 'wireguard',
+                settings: {
+                    secretKey: this.nordData.private_key,
+                    address: ['10.5.0.2/32'],
+                    peers: [{
+                        publicKey: publicKey,
+                        endpoint: server.station + ':51820'
+                    }],
+                    noKernelTun: false
+                }
+            };
+            app.templateSettings.outbounds[index] = outbound;
+            
+            // Sync routing rules
+            app.templateSettings.routing.rules.forEach(r => {
+                if (r.outboundTag === oldTag) {
+                    r.outboundTag = newTag;
+                }
+            });
+
+            app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
+            this.close();
+            app.$message.success('NordVPN outbound updated');
+        },
+        delOutbound(index) {
+            if (index !== -1) {
+                app.templateSettings.outbounds.splice(index, 1);
+                app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
+            }
+        },
+        delRouting() {
+            if (app.templateSettings && app.templateSettings.routing) {
+                app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag.startsWith("nord-"));
+            }
+        }
+    };
+
+    new Vue({
+        delimiters: ['[[', ']]'],
+        el: '#nord-modal',
+        data: {
+            nordModal: nordModal,
+        },
+        methods: {
+            login: () => nordModal.login(),
+            saveKey: () => nordModal.saveKey(),
+            logout() { nordModal.logout(this.nordOutboundIndex) },
+            fetchServers: () => nordModal.fetchServers(),
+            addOutbound: () => nordModal.addOutbound(),
+            resetOutbound() { nordModal.resetOutbound(this.nordOutboundIndex) },
+            onCityChange() {
+                if (this.filteredServers.length > 0) {
+                    this.nordModal.serverId = this.filteredServers[0].id;
+                } else {
+                    this.nordModal.serverId = null;
+                }
+            }
+        },
+        computed: {
+            nordOutboundIndex: {
+                get: function () {
+                    return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) : -1;
+                }
+            },
+            filteredServers: function() {
+                if (!this.nordModal.cityId) {
+                    return this.nordModal.servers;
+                }
+                return this.nordModal.servers.filter(s => s.cityId === this.nordModal.cityId);
+            }
+        }
+    });
+</script>
+{{end}}

+ 4 - 7
web/html/settings.html

@@ -6,7 +6,7 @@
   <a-sidebar></a-sidebar>
   <a-sidebar></a-sidebar>
   <a-layout id="content-layout">
   <a-layout id="content-layout">
     <a-layout-content>
     <a-layout-content>
-      <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
+      <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
         <transition name="list" appear>
         <transition name="list" appear>
           <a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
           <a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
             message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
             message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
@@ -21,10 +21,7 @@
         <transition name="list" appear>
         <transition name="list" appear>
           <template>
           <template>
             <a-row v-if="!loadingStates.fetched">
             <a-row v-if="!loadingStates.fetched">
-              <a-card
-                :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
-                <a-spin tip='{{ i18n "loading" }}'></a-spin>
-              </a-card>
+              <div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
             </a-row>
             </a-row>
             <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
             <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
               <a-col>
               <a-col>
@@ -82,10 +79,10 @@
                     </template>
                     </template>
                     {{ template "settings/panel/subscription/general" . }}
                     {{ template "settings/panel/subscription/general" . }}
                   </a-tab-pane>
                   </a-tab-pane>
-                  <a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
+                  <a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
                     <template #tab>
                     <template #tab>
                       <a-icon type="code"></a-icon>
                       <a-icon type="code"></a-icon>
-                      <span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
+                      <span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
                     </template>
                     </template>
                     {{ template "settings/panel/subscription/json" . }}
                     {{ template "settings/panel/subscription/json" . }}
                   </a-tab-pane>
                   </a-tab-pane>

+ 60 - 26
web/html/settings/panel/subscription/general.html

@@ -3,43 +3,58 @@
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subEnable"}}</template>
             <template #title>{{ i18n "pages.settings.subEnable"}}</template>
-            <template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subEnableDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subEnable"></a-switch>
                 <a-switch v-model="allSetting.subEnable"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>JSON Subscription</template>
             <template #title>JSON Subscription</template>
-            <template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subJsonEnable"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subJsonEnable"></a-switch>
                 <a-switch v-model="allSetting.subJsonEnable"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
+        <a-setting-list-item paddings="small">
+            <template #title>Clash / Mihomo Subscription</template>
+            <template #description>Enable direct Clash and Mihomo YAML
+                subscriptions.</template>
+            <template #control>
+                <a-switch v-model="allSetting.subClashEnable"></a-switch>
+            </template>
+        </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subListen"}}</template>
             <template #title>{{ i18n "pages.settings.subListen"}}</template>
-            <template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subListenDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subListen"></a-input>
                 <a-input type="text" v-model="allSetting.subListen"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subDomain"}}</template>
             <template #title>{{ i18n "pages.settings.subDomain"}}</template>
-            <template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subDomainDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subDomain"></a-input>
                 <a-input type="text" v-model="allSetting.subDomain"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subPort"}}</template>
             <template #title>{{ i18n "pages.settings.subPort"}}</template>
-            <template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subPortDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
+                <a-input-number v-model="allSetting.subPort" :min="1"
+                    :min="65535"
                     :style="{ width: '100%' }"></a-input-number>
                     :style="{ width: '100%' }"></a-input-number>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subPath"}}</template>
             <template #title>{{ i18n "pages.settings.subPath"}}</template>
-            <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subPath"
                 <a-input type="text" v-model="allSetting.subPath"
                     @input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
                     @input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@@ -49,9 +64,11 @@
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subURI"}}</template>
             <template #title>{{ i18n "pages.settings.subURI"}}</template>
-            <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subURIDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
+                <a-input type="text"
+                    placeholder="(http|https)://domain[:port]/path/"
                     v-model="allSetting.subURI"></a-input>
                     v-model="allSetting.subURI"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
@@ -59,14 +76,16 @@
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
             <template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
-            <template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subEncryptDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subEncrypt"></a-switch>
                 <a-switch v-model="allSetting.subEncrypt"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
             <template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
-            <template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subShowInfoDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subShowInfo"></a-switch>
                 <a-switch v-model="allSetting.subShowInfo"></a-switch>
             </template>
             </template>
@@ -74,59 +93,72 @@
         <a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
         <a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subTitle"}}</template>
             <template #title>{{ i18n "pages.settings.subTitle"}}</template>
-            <template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subTitleDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subTitle"></a-input>
                 <a-input type="text" v-model="allSetting.subTitle"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
             <template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
-            <template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subSupportUrlDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
+                <a-input type="text" v-model="allSetting.subSupportUrl"
+                    placeholder="https://example.com"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
             <template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
-            <template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subProfileUrlDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
+                <a-input type="text" v-model="allSetting.subProfileUrl"
+                    placeholder="https://example.com"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
             <template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
-            <template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subAnnounceDesc"}}</template>
             <template #control>
             <template #control>
                 <a-textarea v-model="allSetting.subAnnounce"></a-textarea>
                 <a-textarea v-model="allSetting.subAnnounce"></a-textarea>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
         <a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
-            <template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
+            <template #title>{{ i18n
+                "pages.settings.subEnableRouting"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subEnableRoutingDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subEnableRouting"></a-switch>
                 <a-switch v-model="allSetting.subEnableRouting"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
-            <template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
+            <template #title>{{ i18n
+                "pages.settings.subRoutingRules"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subRoutingRulesDesc"}}</template>
             <template #control>
             <template #control>
-                <a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
+                <a-textarea v-model="allSetting.subRoutingRules"
+                    placeholder="happ://routing/add/..."></a-textarea>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>
     <a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
     <a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subCertPath"}}</template>
             <template #title>{{ i18n "pages.settings.subCertPath"}}</template>
-            <template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subCertPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subCertFile"></a-input>
                 <a-input type="text" v-model="allSetting.subCertFile"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
             <template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
-            <template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subKeyPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subKeyFile"></a-input>
                 <a-input type="text" v-model="allSetting.subKeyFile"></a-input>
             </template>
             </template>
@@ -135,9 +167,11 @@
     <a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
     <a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subUpdates"}}</template>
             <template #title>{{ i18n "pages.settings.subUpdates"}}</template>
-            <template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subUpdatesDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
+                <a-input-number :min="1" v-model="allSetting.subUpdates"
+                    :style="{ width: '100%' }"></a-input-number>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>

+ 22 - 4
web/html/settings/panel/subscription/json.html

@@ -1,8 +1,8 @@
 {{define "settings/panel/subscription/json"}}
 {{define "settings/panel/subscription/json"}}
 <a-collapse default-active-key="1">
 <a-collapse default-active-key="1">
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
-        <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subPath"}}</template>
+        <a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
+            <template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
             <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
             <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subJsonPath"
                 <a-input type="text" v-model="allSetting.subJsonPath"
@@ -11,14 +11,32 @@
                     placeholder="/json/"></a-input>
                     placeholder="/json/"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
-        <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subURI"}}</template>
+        <a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
+            <template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
             <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
             <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
                 <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
                     v-model="allSetting.subJsonURI"></a-input>
                     v-model="allSetting.subJsonURI"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
+        <a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
+            <template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
+            <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
+            <template #control>
+                <a-input type="text" v-model="allSetting.subClashPath"
+                    @input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
+                    @blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
+                    placeholder="/clash/"></a-input>
+            </template>
+        </a-setting-list-item>
+        <a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
+            <template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
+            <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
+            <template #control>
+                <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
+                    v-model="allSetting.subClashURI"></a-input>
+            </template>
+        </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">

+ 15 - 2
web/html/settings/panel/subscription/subpage.html

@@ -83,7 +83,7 @@
                         <a-form-item>
                         <a-form-item>
                             <a-space direction="vertical" align="center">
                             <a-space direction="vertical" align="center">
                                 <a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
                                 <a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
-                                    <a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
+                                    <a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
                                         <tr-qr-box class="qr-box">
                                         <tr-qr-box class="qr-box">
                                             <a-tag color="purple" class="qr-tag">
                                             <a-tag color="purple" class="qr-tag">
                                                 <span>{{ i18n
                                                 <span>{{ i18n
@@ -112,6 +112,19 @@
                                             </tr-qr-bg>
                                             </tr-qr-bg>
                                         </tr-qr-box>
                                         </tr-qr-box>
                                     </a-col>
                                     </a-col>
+                                    <a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
+                                        <tr-qr-box class="qr-box">
+                                            <a-tag color="purple" class="qr-tag">
+                                                <span>Clash / Mihomo</span>
+                                            </a-tag>
+                                            <tr-qr-bg class="qr-bg-sub">
+                                                <tr-qr-bg-inner class="qr-bg-sub-inner">
+                                                    <canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
+                                                        @click="copy(app.subClashUrl)"></canvas>
+                                                </tr-qr-bg-inner>
+                                            </tr-qr-bg>
+                                        </tr-qr-box>
+                                    </a-col>
                                 </a-row>
                                 </a-row>
                             </a-space>
                             </a-space>
                         </a-form-item>
                         </a-form-item>
@@ -242,7 +255,7 @@
 </a-layout>
 </a-layout>
 
 
 <!-- Bootstrap data for external JS -->
 <!-- Bootstrap data for external JS -->
-<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
+<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
     data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
     data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
     data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
     data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
     data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
     data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"

+ 19 - 0
web/html/settings/xray/basics.html

@@ -313,6 +313,25 @@
                 </template>
                 </template>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
+        <a-setting-list-item paddings="small">
+            <template #title>{{ i18n "pages.xray.nordRouting" }}</template>
+            <template #control>
+                <template v-if="NordExist">
+                    <a-select mode="tags" :style="{ width: '100%' }"
+                        v-model="nordDomains"
+                        :dropdown-class-name="themeSwitcher.currentTheme">
+                        <a-select-option :value="p.value" :label="p.label"
+                            v-for="p in settingsData.ServicesOptions">
+                            <span>[[ p.label ]]</span>
+                        </a-select-option>
+                    </a-select>
+                </template>
+                <template v-else>
+                    <a-button type="primary" icon="api"
+                        @click="showNord()">{{ i18n "pages.xray.outbound.nordvpn" }}</a-button>
+                </template>
+            </template>
+        </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>
     <a-collapse-panel key="6"
     <a-collapse-panel key="6"
         header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
         header='{{ i18n "pages.settings.resetDefaultConfig"}}'>

+ 2 - 0
web/html/settings/xray/outbounds.html

@@ -9,6 +9,8 @@
                 </a-button>
                 </a-button>
                 <a-button type="primary" icon="cloud"
                 <a-button type="primary" icon="cloud"
                     @click="showWarp()">WARP</a-button>
                     @click="showWarp()">WARP</a-button>
+                <a-button type="primary" icon="api"
+                    @click="showNord()">NordVPN</a-button>
             </a-space>
             </a-space>
         </a-col>
         </a-col>
         <a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
         <a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">

+ 59 - 5
web/html/xray.html

@@ -14,7 +14,7 @@
   <a-layout id="content-layout">
   <a-layout id="content-layout">
     <a-layout-content>
     <a-layout-content>
       <a-spin :spinning="loadingStates.spinning" :delay="500"
       <a-spin :spinning="loadingStates.spinning" :delay="500"
-        tip='{{ i18n "loading"}}'>
+        tip='{{ i18n "loading"}}' size="large">
         <transition name="list" appear>
         <transition name="list" appear>
           <a-alert type="error" v-if="showAlert && loadingStates.fetched"
           <a-alert type="error" v-if="showAlert && loadingStates.fetched"
             :style="{ marginBottom: '10px' }"
             :style="{ marginBottom: '10px' }"
@@ -24,10 +24,7 @@
         </transition>
         </transition>
         <transition name="list" appear>
         <transition name="list" appear>
           <a-row v-if="!loadingStates.fetched">
           <a-row v-if="!loadingStates.fetched">
-            <a-card
-              :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
-              <a-spin tip='{{ i18n "loading" }}'></a-spin>
-            </a-card>
+            <div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
           </a-row>
           </a-row>
           <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
           <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
             <a-col>
             <a-col>
@@ -166,6 +163,7 @@
 {{template "modals/dnsPresetsModal"}}
 {{template "modals/dnsPresetsModal"}}
 {{template "modals/fakednsModal"}}
 {{template "modals/fakednsModal"}}
 {{template "modals/warpModal"}}
 {{template "modals/warpModal"}}
+{{template "modals/nordModal"}}
 <script>
 <script>
   const rulesColumns = [
   const rulesColumns = [
     { title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
     { title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
@@ -262,6 +260,7 @@
       refreshing: false,
       refreshing: false,
       restartResult: '',
       restartResult: '',
       showAlert: false,
       showAlert: false,
+      customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
       advSettings: 'xraySetting',
       advSettings: 'xraySetting',
       obsSettings: '',
       obsSettings: '',
       cm: null,
       cm: null,
@@ -1057,6 +1056,34 @@
       },
       },
       showWarp() {
       showWarp() {
         warpModal.show();
         warpModal.show();
+      },
+      showNord() {
+        nordModal.show();
+      },
+      async loadCustomGeoAliases() {
+        try {
+          const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
+          if (!msg.success) {
+            console.warn('Failed to load custom geo aliases:', msg.msg || 'request failed');
+            return;
+          }
+          if (!msg.obj) return;
+          const { geoip = [], geosite = [] } = msg.obj;
+          const geoSuffix = this.customGeoAliasLabelSuffix || '';
+          geoip.forEach((x) => {
+            this.settingsData.IPsOptions.push({
+              label: x.alias + geoSuffix,
+              value: x.extExample,
+            });
+          });
+          geosite.forEach((x) => {
+            const opt = { label: x.alias + geoSuffix, value: x.extExample };
+            this.settingsData.DomainsOptions.push(opt);
+            this.settingsData.BlockDomainsOptions.push(opt);
+          });
+        } catch (e) {
+          console.error('Failed to load custom geo aliases:', e);
+        }
       }
       }
     },
     },
     async mounted() {
     async mounted() {
@@ -1064,6 +1091,7 @@
         this.showAlert = true;
         this.showAlert = true;
       }
       }
       await this.getXraySetting();
       await this.getXraySetting();
+      await this.loadCustomGeoAliases();
       await this.getXrayResult();
       await this.getXrayResult();
       await this.getOutboundsTraffic();
       await this.getOutboundsTraffic();
 
 
@@ -1075,6 +1103,14 @@
               this.$forceUpdate();
               this.$forceUpdate();
             }
             }
           });
           });
+
+          // Handle invalidate signals (sent when payload is too large for WebSocket,
+          // or when traffic job notifies about data changes)
+          window.wsClient.on('invalidate', (payload) => {
+            if (payload && payload.type === 'outbounds') {
+              this.refreshOutboundTraffic();
+            }
+          });
       }
       }
 
 
       while (true) {
       while (true) {
@@ -1397,6 +1433,19 @@
           this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
           this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
         }
         }
       },
       },
+      nordTag: {
+        get: function () {
+          return this.templateSettings ? (this.templateSettings.outbounds.find((o) => o.tag.startsWith("nord-")) || { tag: "nord" }).tag : "nord";
+        }
+      },
+      nordDomains: {
+        get: function () {
+          return this.templateRuleGetter({ outboundTag: this.nordTag, property: "domain" });
+        },
+        set: function (newValue) {
+          this.templateRuleSetter({ outboundTag: this.nordTag, property: "domain", data: newValue });
+        }
+      },
       torrentSettings: {
       torrentSettings: {
         get: function () {
         get: function () {
           return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
           return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
@@ -1414,6 +1463,11 @@
           return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
           return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
         },
         },
       },
       },
+      NordExist: {
+        get: function () {
+          return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) >= 0 : false;
+        },
+      },
       enableDNS: {
       enableDNS: {
         get: function () {
         get: function () {
           return this.templateSettings ? this.templateSettings.dns != null : false;
           return this.templateSettings ? this.templateSettings.dns != null : false;

+ 133 - 0
web/job/check_client_ip_job.go

@@ -3,6 +3,7 @@ package job
 import (
 import (
 	"bufio"
 	"bufio"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"io"
 	"io"
 	"log"
 	"log"
 	"os"
 	"os"
@@ -32,6 +33,8 @@ type CheckClientIpJob struct {
 
 
 var job *CheckClientIpJob
 var job *CheckClientIpJob
 
 
+const defaultXrayAPIPort = 62789
+
 // NewCheckClientIpJob creates a new client IP monitoring job instance.
 // NewCheckClientIpJob creates a new client IP monitoring job instance.
 func NewCheckClientIpJob() *CheckClientIpJob {
 func NewCheckClientIpJob() *CheckClientIpJob {
 	job = new(CheckClientIpJob)
 	job = new(CheckClientIpJob)
@@ -355,6 +358,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 			log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
 			log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
 		}
 		}
 
 
+		// Actually disconnect banned IPs by temporarily removing and re-adding user
+		// This forces Xray to drop existing connections from banned IPs
+		if len(bannedIps) > 0 {
+			j.disconnectClientTemporarily(inbound, clientEmail, clients)
+		}
+
 		// Update database with only the currently active (kept) IPs
 		// Update database with only the currently active (kept) IPs
 		jsonIps, _ := json.Marshal(keptIps)
 		jsonIps, _ := json.Marshal(keptIps)
 		inboundClientIps.Ips = string(jsonIps)
 		inboundClientIps.Ips = string(jsonIps)
@@ -378,6 +387,130 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	return shouldCleanLog
 	return shouldCleanLog
 }
 }
 
 
+// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
+func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
+	var xrayAPI xray.XrayAPI
+	apiPort := j.resolveXrayAPIPort()
+
+	err := xrayAPI.Init(apiPort)
+	if err != nil {
+		logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
+		return
+	}
+	defer xrayAPI.Close()
+
+	// Find the client config
+	var clientConfig map[string]any
+	for _, client := range clients {
+		if client.Email == clientEmail {
+			// Convert client to map for API
+			clientBytes, _ := json.Marshal(client)
+			json.Unmarshal(clientBytes, &clientConfig)
+			break
+		}
+	}
+
+	if clientConfig == nil {
+		return
+	}
+
+	// Only perform remove/re-add for protocols supported by XrayAPI.AddUser
+	protocol := string(inbound.Protocol)
+	switch protocol {
+	case "vmess", "vless", "trojan", "shadowsocks":
+		// supported protocols, continue
+	default:
+		logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
+		return
+	}
+
+	// For Shadowsocks, ensure the required "cipher" field is present by
+	// reading it from the inbound settings (e.g., settings["method"]).
+	if string(inbound.Protocol) == "shadowsocks" {
+		var inboundSettings map[string]any
+		if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
+			logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
+		} else {
+			if method, ok := inboundSettings["method"].(string); ok && method != "" {
+				clientConfig["cipher"] = method
+			}
+		}
+	}
+
+	// Remove user to disconnect all connections
+	err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
+	if err != nil {
+		logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
+		return
+	}
+
+	// Wait a moment for disconnection to take effect
+	time.Sleep(100 * time.Millisecond)
+
+	// Re-add user to allow new connections
+	err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
+	if err != nil {
+		logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
+	}
+}
+
+// resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
+func (j *CheckClientIpJob) resolveXrayAPIPort() int {
+	var configErr error
+	var templateErr error
+
+	if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
+		return port
+	} else {
+		configErr = err
+	}
+
+	db := database.GetDB()
+	var template model.Setting
+	if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
+		if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
+			return port
+		} else {
+			templateErr = parseErr
+		}
+	} else {
+		templateErr = err
+	}
+
+	logger.Warningf(
+		"[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
+		defaultXrayAPIPort,
+		configErr,
+		templateErr,
+	)
+
+	return defaultXrayAPIPort
+}
+
+func getAPIPortFromConfigPath(configPath string) (int, error) {
+	configData, err := os.ReadFile(configPath)
+	if err != nil {
+		return 0, err
+	}
+
+	return getAPIPortFromConfigData(configData)
+}
+
+func getAPIPortFromConfigData(configData []byte) (int, error) {
+	xrayConfig := &xray.Config{}
+	if err := json.Unmarshal(configData, xrayConfig); err != nil {
+		return 0, err
+	}
+
+	for _, inboundConfig := range xrayConfig.InboundConfigs {
+		if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
+			return inboundConfig.Port, nil
+		}
+	}
+
+	return 0, errors.New("api inbound port not found")
+}
+
 func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
 func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	inbound := &model.Inbound{}
 	inbound := &model.Inbound{}

+ 1 - 1
web/job/periodic_traffic_reset_job.go

@@ -37,7 +37,7 @@ func (j *PeriodicTrafficResetJob) Run() {
 	resetCount := 0
 	resetCount := 0
 
 
 	for _, inbound := range inbounds {
 	for _, inbound := range inbounds {
-		resetInboundErr := j.inboundService.ResetAllTraffics()
+		resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
 		if resetInboundErr != nil {
 		if resetInboundErr != nil {
 			logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
 			logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
 		}
 		}

+ 19 - 13
web/job/xray_traffic_job.go

@@ -50,7 +50,13 @@ func (j *XrayTrafficJob) Run() {
 		j.xrayService.SetToNeedRestart()
 		j.xrayService.SetToNeedRestart()
 	}
 	}
 
 
-	// Get online clients and last online map for real-time status updates
+	// If no frontend client is connected, skip all WebSocket broadcasting routines,
+	// including expensive DB queries for online clients and JSON marshaling.
+	if !websocket.HasClients() {
+		return
+	}
+
+	// Update online clients list and map
 	onlineClients := j.inboundService.GetOnlineClients()
 	onlineClients := j.inboundService.GetOnlineClients()
 	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
 	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
 	if err != nil {
 	if err != nil {
@@ -58,8 +64,17 @@ func (j *XrayTrafficJob) Run() {
 		lastOnlineMap = make(map[string]int64)
 		lastOnlineMap = make(map[string]int64)
 	}
 	}
 
 
+	// Broadcast traffic update (deltas and online stats) via WebSocket
+	trafficUpdate := map[string]any{
+		"traffics":       traffics,
+		"clientTraffics": clientTraffics,
+		"onlineClients":  onlineClients,
+		"lastOnlineMap":  lastOnlineMap,
+	}
+	websocket.BroadcastTraffic(trafficUpdate)
+
 	// Fetch updated inbounds from database with accumulated traffic values
 	// Fetch updated inbounds from database with accumulated traffic values
-	// This ensures frontend receives the actual total traffic, not just delta values
+	// This ensures frontend receives the actual total traffic for real-time UI refresh.
 	updatedInbounds, err := j.inboundService.GetAllInbounds()
 	updatedInbounds, err := j.inboundService.GetAllInbounds()
 	if err != nil {
 	if err != nil {
 		logger.Warning("get all inbounds for websocket failed:", err)
 		logger.Warning("get all inbounds for websocket failed:", err)
@@ -70,16 +85,8 @@ func (j *XrayTrafficJob) Run() {
 		logger.Warning("get all outbounds for websocket failed:", err)
 		logger.Warning("get all outbounds for websocket failed:", err)
 	}
 	}
 
 
-	// Broadcast traffic update via WebSocket with accumulated values from database
-	trafficUpdate := map[string]any{
-		"traffics":       traffics,
-		"clientTraffics": clientTraffics,
-		"onlineClients":  onlineClients,
-		"lastOnlineMap":  lastOnlineMap,
-	}
-	websocket.BroadcastTraffic(trafficUpdate)
-
-	// Broadcast full inbounds update for real-time UI refresh
+	// The WebSocket hub will automatically check the payload size.
+	// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
 	if updatedInbounds != nil {
 	if updatedInbounds != nil {
 		websocket.BroadcastInbounds(updatedInbounds)
 		websocket.BroadcastInbounds(updatedInbounds)
 	}
 	}
@@ -87,7 +94,6 @@ func (j *XrayTrafficJob) Run() {
 	if updatedOutbounds != nil {
 	if updatedOutbounds != nil {
 		websocket.BroadcastOutbounds(updatedOutbounds)
 		websocket.BroadcastOutbounds(updatedOutbounds)
 	}
 	}
-
 }
 }
 
 
 func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
 func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {

+ 760 - 0
web/service/custom_geo.go

@@ -0,0 +1,760 @@
+package service
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v2/config"
+	"github.com/mhsanaei/3x-ui/v2/database"
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v2/logger"
+)
+
+const (
+	customGeoTypeGeosite  = "geosite"
+	customGeoTypeGeoip    = "geoip"
+	minDatBytes           = 64
+	customGeoProbeTimeout = 12 * time.Second
+)
+
+var (
+	customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
+	reservedCustomAliases = map[string]struct{}{
+		"geoip": {}, "geosite": {},
+		"geoip_ir": {}, "geosite_ir": {},
+		"geoip_ru": {}, "geosite_ru": {},
+	}
+	ErrCustomGeoInvalidType    = errors.New("custom_geo_invalid_type")
+	ErrCustomGeoAliasRequired  = errors.New("custom_geo_alias_required")
+	ErrCustomGeoAliasPattern   = errors.New("custom_geo_alias_pattern")
+	ErrCustomGeoAliasReserved  = errors.New("custom_geo_alias_reserved")
+	ErrCustomGeoURLRequired    = errors.New("custom_geo_url_required")
+	ErrCustomGeoInvalidURL     = errors.New("custom_geo_invalid_url")
+	ErrCustomGeoURLScheme      = errors.New("custom_geo_url_scheme")
+	ErrCustomGeoURLHost        = errors.New("custom_geo_url_host")
+	ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
+	ErrCustomGeoNotFound       = errors.New("custom_geo_not_found")
+	ErrCustomGeoDownload       = errors.New("custom_geo_download")
+	ErrCustomGeoSSRFBlocked    = errors.New("custom_geo_ssrf_blocked")
+	ErrCustomGeoPathTraversal  = errors.New("custom_geo_path_traversal")
+)
+
+type CustomGeoUpdateAllItem struct {
+	Id       int    `json:"id"`
+	Alias    string `json:"alias"`
+	FileName string `json:"fileName"`
+}
+
+type CustomGeoUpdateAllFailure struct {
+	Id       int    `json:"id"`
+	Alias    string `json:"alias"`
+	FileName string `json:"fileName"`
+	Err      string `json:"error"`
+}
+
+type CustomGeoUpdateAllResult struct {
+	Succeeded []CustomGeoUpdateAllItem    `json:"succeeded"`
+	Failed    []CustomGeoUpdateAllFailure `json:"failed"`
+}
+
+type CustomGeoService struct {
+	serverService    *ServerService
+	updateAllGetAll  func() ([]model.CustomGeoResource, error)
+	updateAllApply   func(id int, onStartup bool) (string, error)
+	updateAllRestart func() error
+}
+
+func NewCustomGeoService() *CustomGeoService {
+	s := &CustomGeoService{
+		serverService: &ServerService{},
+	}
+	s.updateAllGetAll = s.GetAll
+	s.updateAllApply = s.applyDownloadAndPersist
+	s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
+	return s
+}
+
+func NormalizeAliasKey(alias string) string {
+	return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
+}
+
+func (s *CustomGeoService) fileNameFor(typ, alias string) string {
+	if typ == customGeoTypeGeoip {
+		return fmt.Sprintf("geoip_%s.dat", alias)
+	}
+	return fmt.Sprintf("geosite_%s.dat", alias)
+}
+
+func (s *CustomGeoService) validateType(typ string) error {
+	if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
+		return ErrCustomGeoInvalidType
+	}
+	return nil
+}
+
+func (s *CustomGeoService) validateAlias(alias string) error {
+	if alias == "" {
+		return ErrCustomGeoAliasRequired
+	}
+	if !customGeoAliasPattern.MatchString(alias) {
+		return ErrCustomGeoAliasPattern
+	}
+	if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
+		return ErrCustomGeoAliasReserved
+	}
+	return nil
+}
+
+func (s *CustomGeoService) sanitizeURL(raw string) (string, error) {
+	if raw == "" {
+		return "", ErrCustomGeoURLRequired
+	}
+	u, err := url.Parse(raw)
+	if err != nil {
+		return "", ErrCustomGeoInvalidURL
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return "", ErrCustomGeoURLScheme
+	}
+	if u.Host == "" {
+		return "", ErrCustomGeoURLHost
+	}
+	if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
+		return "", err
+	}
+	// Reconstruct URL from parsed components to break taint propagation.
+	clean := &url.URL{
+		Scheme:   u.Scheme,
+		Host:     u.Host,
+		Path:     u.Path,
+		RawPath:  u.RawPath,
+		RawQuery: u.RawQuery,
+		Fragment: u.Fragment,
+	}
+	return clean.String(), nil
+}
+
+func localDatFileNeedsRepair(path string) bool {
+	safePath, err := sanitizeDestPath(path)
+	if err != nil {
+		return true
+	}
+	fi, err := os.Stat(safePath)
+	if err != nil {
+		return true
+	}
+	if fi.IsDir() {
+		return true
+	}
+	return fi.Size() < int64(minDatBytes)
+}
+
+func CustomGeoLocalFileNeedsRepair(path string) bool {
+	return localDatFileNeedsRepair(path)
+}
+
+func isBlockedIP(ip net.IP) bool {
+	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
+}
+
+// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
+// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
+func checkSSRFDefault(ctx context.Context, hostname string) error {
+	ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
+	if err != nil {
+		return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
+	}
+	for _, ipAddr := range ips {
+		if isBlockedIP(ipAddr.IP) {
+			return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
+		}
+	}
+	return nil
+}
+
+// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
+var checkSSRF = checkSSRFDefault
+
+func ssrfSafeTransport() http.RoundTripper {
+	base, ok := http.DefaultTransport.(*http.Transport)
+	if !ok {
+		base = &http.Transport{}
+	}
+	cloned := base.Clone()
+	cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+		host, _, err := net.SplitHostPort(addr)
+		if err != nil {
+			return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
+		}
+		if err := checkSSRF(ctx, host); err != nil {
+			return nil, err
+		}
+		var dialer net.Dialer
+		return dialer.DialContext(ctx, network, addr)
+	}
+	return cloned
+}
+
+func probeCustomGeoURLWithGET(rawURL string) error {
+	sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
+	if err != nil {
+		return err
+	}
+	client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
+	req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Range", "bytes=0-0")
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
+	switch resp.StatusCode {
+	case http.StatusOK, http.StatusPartialContent:
+		return nil
+	default:
+		return fmt.Errorf("get range status %d", resp.StatusCode)
+	}
+}
+
+func probeCustomGeoURL(rawURL string) error {
+	sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
+	if err != nil {
+		return err
+	}
+	client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
+	req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
+	if err != nil {
+		return err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	_ = resp.Body.Close()
+	sc := resp.StatusCode
+	if sc >= 200 && sc < 300 {
+		return nil
+	}
+	if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
+		return probeCustomGeoURLWithGET(rawURL)
+	}
+	return fmt.Errorf("head status %d", sc)
+}
+
+func (s *CustomGeoService) EnsureOnStartup() {
+	list, err := s.GetAll()
+	if err != nil {
+		logger.Warning("custom geo startup: load list:", err)
+		return
+	}
+	n := len(list)
+	if n == 0 {
+		logger.Info("custom geo startup: no custom geofiles configured")
+		return
+	}
+	logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
+	for i := range list {
+		r := &list[i]
+		sanitizedURL, err := s.sanitizeURL(r.Url)
+		if err != nil {
+			logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
+			continue
+		}
+		r.Url = sanitizedURL
+		s.syncLocalPath(r)
+		localPath := r.LocalPath
+		if !localDatFileNeedsRepair(localPath) {
+			logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
+			continue
+		}
+		logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
+		if err := probeCustomGeoURL(r.Url); err != nil {
+			logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
+		}
+		_, _ = s.applyDownloadAndPersist(r.Id, true)
+	}
+}
+
+func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
+	safeDestPath, err := sanitizeDestPath(destPath)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
+	if err != nil {
+		return false, "", err
+	}
+	if skipped {
+		if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
+			return true, lm, nil
+		}
+		return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
+	}
+	return false, lm, nil
+}
+
+// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
+// It resolves symlinks to prevent symlink-based escapes.
+// Returns the cleaned absolute path that is safe to use in file operations.
+func sanitizeDestPath(destPath string) (string, error) {
+	baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
+	if err != nil {
+		return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
+	}
+	// Resolve symlinks in base directory to get the real path.
+	if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
+		baseDirAbs = resolved
+	}
+	destPathAbs, err := filepath.Abs(destPath)
+	if err != nil {
+		return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
+	}
+	// Resolve symlinks for the parent directory of the destination path.
+	destDir := filepath.Dir(destPathAbs)
+	if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
+		destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
+	}
+	// Verify the resolved path is within the safe base directory using prefix check.
+	safeDirPrefix := baseDirAbs + string(filepath.Separator)
+	if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
+		return "", ErrCustomGeoPathTraversal
+	}
+	return destPathAbs, nil
+}
+
+func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
+	safeDestPath, err := sanitizeDestPath(destPath)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	sanitizedURL, err := s.sanitizeURL(resourceURL)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	var req *http.Request
+	req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	if !forceFull {
+		if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
+			if !fi.ModTime().IsZero() {
+				req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
+			} else if lastModifiedHeader != "" {
+				if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
+					req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
+				}
+			}
+		}
+	}
+
+	client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()}
+	// lgtm[go/request-forgery]
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	defer resp.Body.Close()
+
+	var serverModTime time.Time
+	if lm := resp.Header.Get("Last-Modified"); lm != "" {
+		if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
+			serverModTime = parsed
+			newLastModified = lm
+		}
+	}
+
+	updateModTime := func() {
+		if !serverModTime.IsZero() {
+			_ = os.Chtimes(safeDestPath, serverModTime, serverModTime)
+		}
+	}
+
+	if resp.StatusCode == http.StatusNotModified {
+		if forceFull {
+			return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
+		}
+		updateModTime()
+		return true, newLastModified, nil
+	}
+	if resp.StatusCode != http.StatusOK {
+		return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
+	}
+
+	binDir := filepath.Dir(safeDestPath)
+	if err = os.MkdirAll(binDir, 0o755); err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	out, err := os.Create(safeTmpPath)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	n, err := io.Copy(out, resp.Body)
+	closeErr := out.Close()
+	if err != nil {
+		_ = os.Remove(safeTmpPath)
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	if closeErr != nil {
+		_ = os.Remove(safeTmpPath)
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
+	}
+	if n < minDatBytes {
+		_ = os.Remove(safeTmpPath)
+		return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
+	}
+
+	if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
+		_ = os.Remove(safeTmpPath)
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	updateModTime()
+	if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
+		newLastModified = resp.Header.Get("Last-Modified")
+	}
+	return false, newLastModified, nil
+}
+
+func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
+	if r.LocalPath != "" {
+		return r.LocalPath
+	}
+	return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
+}
+
+func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
+	p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
+	r.LocalPath = p
+}
+
+func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
+	s.syncLocalPath(r)
+	safePath, err := sanitizeDestPath(r.LocalPath)
+	if err != nil {
+		return err
+	}
+	r.LocalPath = safePath
+	return nil
+}
+
+func removeSafePathIfExists(path string) error {
+	safePath, err := sanitizeDestPath(path)
+	if err != nil {
+		return err
+	}
+	if _, err := os.Stat(safePath); err == nil {
+		if err := os.Remove(safePath); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
+	if err := s.validateType(r.Type); err != nil {
+		return err
+	}
+	if err := s.validateAlias(r.Alias); err != nil {
+		return err
+	}
+	sanitizedURL, err := s.sanitizeURL(r.Url)
+	if err != nil {
+		return err
+	}
+	r.Url = sanitizedURL
+	var existing int64
+	database.GetDB().Model(&model.CustomGeoResource{}).
+		Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
+	if existing > 0 {
+		return ErrCustomGeoDuplicateAlias
+	}
+	if err := s.syncAndSanitizeLocalPath(r); err != nil {
+		return err
+	}
+	skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
+	if err != nil {
+		return err
+	}
+	now := time.Now().Unix()
+	r.LastUpdatedAt = now
+	r.LastModified = lm
+	if err = database.GetDB().Create(r).Error; err != nil {
+		_ = removeSafePathIfExists(r.LocalPath)
+		return err
+	}
+	logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
+	if err = s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo create: restart xray:", err)
+	}
+	return nil
+}
+
+func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
+	var cur model.CustomGeoResource
+	if err := database.GetDB().First(&cur, id).Error; err != nil {
+		if database.IsNotFound(err) {
+			return ErrCustomGeoNotFound
+		}
+		return err
+	}
+	if err := s.validateType(r.Type); err != nil {
+		return err
+	}
+	if err := s.validateAlias(r.Alias); err != nil {
+		return err
+	}
+	sanitizedURL, err := s.sanitizeURL(r.Url)
+	if err != nil {
+		return err
+	}
+	r.Url = sanitizedURL
+	if cur.Type != r.Type || cur.Alias != r.Alias {
+		var cnt int64
+		database.GetDB().Model(&model.CustomGeoResource{}).
+			Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
+			Count(&cnt)
+		if cnt > 0 {
+			return ErrCustomGeoDuplicateAlias
+		}
+	}
+	oldPath := s.resolveDestPath(&cur)
+	r.Id = id
+	if err := s.syncAndSanitizeLocalPath(r); err != nil {
+		return err
+	}
+	if oldPath != r.LocalPath && oldPath != "" {
+		if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
+			logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
+		}
+	}
+	_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
+	if err != nil {
+		return err
+	}
+	r.LastUpdatedAt = time.Now().Unix()
+	r.LastModified = lm
+	err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
+		"geo_type":        r.Type,
+		"alias":           r.Alias,
+		"url":             r.Url,
+		"local_path":      r.LocalPath,
+		"last_updated_at": r.LastUpdatedAt,
+		"last_modified":   r.LastModified,
+	}).Error
+	if err != nil {
+		return err
+	}
+	logger.Infof("custom geo updated id=%d", id)
+	if err = s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo update: restart xray:", err)
+	}
+	return nil
+}
+
+func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
+	var r model.CustomGeoResource
+	if err := database.GetDB().First(&r, id).Error; err != nil {
+		if database.IsNotFound(err) {
+			return "", ErrCustomGeoNotFound
+		}
+		return "", err
+	}
+	displayName = s.fileNameFor(r.Type, r.Alias)
+	p := s.resolveDestPath(&r)
+	if _, err := sanitizeDestPath(p); err != nil {
+		return displayName, err
+	}
+	if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
+		return displayName, err
+	}
+	if p != "" {
+		if err := removeSafePathIfExists(p); err != nil {
+			logger.Warningf("custom geo delete file %s: %v", p, err)
+		}
+	}
+	logger.Infof("custom geo deleted id=%d", id)
+	if err := s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo delete: restart xray:", err)
+	}
+	return displayName, nil
+}
+
+func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
+	var list []model.CustomGeoResource
+	err := database.GetDB().Order("id asc").Find(&list).Error
+	return list, err
+}
+
+func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
+	var r model.CustomGeoResource
+	if err := database.GetDB().First(&r, id).Error; err != nil {
+		if database.IsNotFound(err) {
+			return "", ErrCustomGeoNotFound
+		}
+		return "", err
+	}
+	displayName = s.fileNameFor(r.Type, r.Alias)
+	if err := s.syncAndSanitizeLocalPath(&r); err != nil {
+		return displayName, err
+	}
+	sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
+	if sanitizeErr != nil {
+		return displayName, sanitizeErr
+	}
+	skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified)
+	if err != nil {
+		if onStartup {
+			logger.Warningf("custom geo startup download id=%d: %v", id, err)
+		} else {
+			logger.Warningf("custom geo manual update id=%d: %v", id, err)
+		}
+		return displayName, err
+	}
+	now := time.Now().Unix()
+	updates := map[string]any{
+		"last_modified":   lm,
+		"local_path":      r.LocalPath,
+		"last_updated_at": now,
+	}
+	if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+		if onStartup {
+			logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
+		} else {
+			logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
+		}
+		return displayName, err
+	}
+	if skipped {
+		if onStartup {
+			logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
+		} else {
+			logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
+		}
+	} else {
+		if onStartup {
+			logger.Infof("custom geo startup download ok id=%d", id)
+		} else {
+			logger.Infof("custom geo manual update ok id=%d", id)
+		}
+	}
+	return displayName, nil
+}
+
+func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
+	displayName, err := s.applyDownloadAndPersist(id, false)
+	if err != nil {
+		return displayName, err
+	}
+	if err = s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo manual update: restart xray:", err)
+	}
+	return displayName, nil
+}
+
+func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
+	var list []model.CustomGeoResource
+	var err error
+	if s.updateAllGetAll != nil {
+		list, err = s.updateAllGetAll()
+	} else {
+		list, err = s.GetAll()
+	}
+	if err != nil {
+		return nil, err
+	}
+	res := &CustomGeoUpdateAllResult{}
+	if len(list) == 0 {
+		return res, nil
+	}
+	for _, r := range list {
+		var name string
+		var applyErr error
+		if s.updateAllApply != nil {
+			name, applyErr = s.updateAllApply(r.Id, false)
+		} else {
+			name, applyErr = s.applyDownloadAndPersist(r.Id, false)
+		}
+		if applyErr != nil {
+			res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
+				Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
+			})
+			continue
+		}
+		res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
+			Id: r.Id, Alias: r.Alias, FileName: name,
+		})
+	}
+	if len(res.Succeeded) > 0 {
+		var restartErr error
+		if s.updateAllRestart != nil {
+			restartErr = s.updateAllRestart()
+		} else {
+			restartErr = s.serverService.RestartXrayService()
+		}
+		if restartErr != nil {
+			logger.Warning("custom geo update all: restart xray:", restartErr)
+		}
+	}
+	return res, nil
+}
+
+type CustomGeoAliasItem struct {
+	Alias      string `json:"alias"`
+	Type       string `json:"type"`
+	FileName   string `json:"fileName"`
+	ExtExample string `json:"extExample"`
+}
+
+type CustomGeoAliasesResponse struct {
+	Geosite []CustomGeoAliasItem `json:"geosite"`
+	Geoip   []CustomGeoAliasItem `json:"geoip"`
+}
+
+func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
+	list, err := s.GetAll()
+	if err != nil {
+		logger.Warning("custom geo GetAliasesForUI:", err)
+		return CustomGeoAliasesResponse{}, err
+	}
+	var out CustomGeoAliasesResponse
+	for _, r := range list {
+		fn := s.fileNameFor(r.Type, r.Alias)
+		ex := fmt.Sprintf("ext:%s:tag", fn)
+		item := CustomGeoAliasItem{
+			Alias:      r.Alias,
+			Type:       r.Type,
+			FileName:   fn,
+			ExtExample: ex,
+		}
+		if r.Type == customGeoTypeGeoip {
+			out.Geoip = append(out.Geoip, item)
+		} else {
+			out.Geosite = append(out.Geosite, item)
+		}
+	}
+	return out, nil
+}

+ 348 - 0
web/service/custom_geo_test.go

@@ -0,0 +1,348 @@
+package service
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+)
+
+// disableSSRFCheck disables the SSRF guard for the duration of a test,
+// allowing httptest servers on localhost. It restores the original on cleanup.
+func disableSSRFCheck(t *testing.T) {
+	t.Helper()
+	orig := checkSSRF
+	checkSSRF = func(_ context.Context, _ string) error { return nil }
+	t.Cleanup(func() { checkSSRF = orig })
+}
+
+func TestNormalizeAliasKey(t *testing.T) {
+	if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
+		t.Fatalf("got %q", got)
+	}
+	if got := NormalizeAliasKey("a-b_c"); got != "a_b_c" {
+		t.Fatalf("got %q", got)
+	}
+}
+
+func TestNewCustomGeoService(t *testing.T) {
+	s := NewCustomGeoService()
+	if err := s.validateAlias("ok_alias-1"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestTriggerUpdateAllAllSuccess(t *testing.T) {
+	s := CustomGeoService{}
+	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
+		return []model.CustomGeoResource{
+			{Id: 1, Alias: "a"},
+			{Id: 2, Alias: "b"},
+		}, nil
+	}
+	s.updateAllApply = func(id int, onStartup bool) (string, error) {
+		return fmt.Sprintf("geo_%d.dat", id), nil
+	}
+	restartCalls := 0
+	s.updateAllRestart = func() error {
+		restartCalls++
+		return nil
+	}
+
+	res, err := s.TriggerUpdateAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(res.Succeeded) != 2 || len(res.Failed) != 0 {
+		t.Fatalf("unexpected result: %+v", res)
+	}
+	if restartCalls != 1 {
+		t.Fatalf("expected 1 restart, got %d", restartCalls)
+	}
+}
+
+func TestTriggerUpdateAllPartialSuccess(t *testing.T) {
+	s := CustomGeoService{}
+	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
+		return []model.CustomGeoResource{
+			{Id: 1, Alias: "ok"},
+			{Id: 2, Alias: "bad"},
+		}, nil
+	}
+	s.updateAllApply = func(id int, onStartup bool) (string, error) {
+		if id == 2 {
+			return "geo_2.dat", ErrCustomGeoDownload
+		}
+		return "geo_1.dat", nil
+	}
+	restartCalls := 0
+	s.updateAllRestart = func() error {
+		restartCalls++
+		return nil
+	}
+
+	res, err := s.TriggerUpdateAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(res.Succeeded) != 1 || len(res.Failed) != 1 {
+		t.Fatalf("unexpected result: %+v", res)
+	}
+	if restartCalls != 1 {
+		t.Fatalf("expected 1 restart, got %d", restartCalls)
+	}
+}
+
+func TestTriggerUpdateAllAllFailure(t *testing.T) {
+	s := CustomGeoService{}
+	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
+		return []model.CustomGeoResource{
+			{Id: 1, Alias: "a"},
+			{Id: 2, Alias: "b"},
+		}, nil
+	}
+	s.updateAllApply = func(id int, onStartup bool) (string, error) {
+		return fmt.Sprintf("geo_%d.dat", id), ErrCustomGeoDownload
+	}
+	restartCalls := 0
+	s.updateAllRestart = func() error {
+		restartCalls++
+		return nil
+	}
+
+	res, err := s.TriggerUpdateAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(res.Succeeded) != 0 || len(res.Failed) != 2 {
+		t.Fatalf("unexpected result: %+v", res)
+	}
+	if restartCalls != 0 {
+		t.Fatalf("expected 0 restart, got %d", restartCalls)
+	}
+}
+
+func TestCustomGeoValidateAlias(t *testing.T) {
+	s := CustomGeoService{}
+	if err := s.validateAlias(""); !errors.Is(err, ErrCustomGeoAliasRequired) {
+		t.Fatal("empty alias")
+	}
+	if err := s.validateAlias("Bad"); !errors.Is(err, ErrCustomGeoAliasPattern) {
+		t.Fatal("uppercase")
+	}
+	if err := s.validateAlias("a b"); !errors.Is(err, ErrCustomGeoAliasPattern) {
+		t.Fatal("space")
+	}
+	if err := s.validateAlias("ok_alias-1"); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.validateAlias("geoip"); !errors.Is(err, ErrCustomGeoAliasReserved) {
+		t.Fatal("reserved")
+	}
+}
+
+func TestCustomGeoValidateURL(t *testing.T) {
+	s := CustomGeoService{}
+	if _, err := s.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
+		t.Fatal("empty")
+	}
+	if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
+		t.Fatal("ftp")
+	}
+	if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil {
+		t.Fatal(err)
+	} else if sanitized != "https://example.com/a.dat" {
+		t.Fatalf("unexpected sanitized URL: %s", sanitized)
+	}
+}
+
+func TestCustomGeoValidateType(t *testing.T) {
+	s := CustomGeoService{}
+	if err := s.validateType("geosite"); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.validateType("x"); !errors.Is(err, ErrCustomGeoInvalidType) {
+		t.Fatal("bad type")
+	}
+}
+
+func TestCustomGeoDownloadToPath(t *testing.T) {
+	disableSSRFCheck(t)
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("X-Test", "1")
+		if r.Header.Get("If-Modified-Since") != "" {
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+	defer ts.Close()
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geoip_t.dat")
+	s := CustomGeoService{}
+	skipped, _, err := s.downloadToPath(ts.URL, dest, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if skipped {
+		t.Fatal("expected download")
+	}
+	st, err := os.Stat(dest)
+	if err != nil || st.Size() < minDatBytes {
+		t.Fatalf("file %v", err)
+	}
+	skipped2, _, err2 := s.downloadToPath(ts.URL, dest, "")
+	if err2 != nil || !skipped2 {
+		t.Fatalf("304 expected skipped=%v err=%v", skipped2, err2)
+	}
+}
+
+func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
+	disableSSRFCheck(t)
+	lm := "Wed, 21 Oct 2015 07:28:00 GMT"
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("If-Modified-Since") != "" {
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		w.Header().Set("Last-Modified", lm)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+	defer ts.Close()
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geoip_rebuild.dat")
+	s := CustomGeoService{}
+	skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if skipped {
+		t.Fatal("must not treat as not-modified when local file is missing")
+	}
+	if _, err := os.Stat(dest); err != nil {
+		t.Fatal("file should exist after container-style rebuild")
+	}
+}
+
+func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
+	disableSSRFCheck(t)
+	lm := "Wed, 21 Oct 2015 07:28:00 GMT"
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("If-Modified-Since") != "" {
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		w.Header().Set("Last-Modified", lm)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+	defer ts.Close()
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geoip_bad.dat")
+	if err := os.WriteFile(dest, make([]byte, minDatBytes-1), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	s := CustomGeoService{}
+	skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if skipped {
+		t.Fatal("corrupt local file must be re-downloaded, not 304")
+	}
+	st, err := os.Stat(dest)
+	if err != nil || st.Size() < minDatBytes {
+		t.Fatalf("file repaired: %v", err)
+	}
+}
+
+func TestCustomGeoFileNameFor(t *testing.T) {
+	s := CustomGeoService{}
+	if s.fileNameFor("geoip", "a") != "geoip_a.dat" {
+		t.Fatal("geoip name")
+	}
+	if s.fileNameFor("geosite", "b") != "geosite_b.dat" {
+		t.Fatal("geosite name")
+	}
+}
+
+func TestLocalDatFileNeedsRepair(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
+		t.Fatal("missing")
+	}
+	smallPath := filepath.Join(dir, "small.dat")
+	if err := os.WriteFile(smallPath, make([]byte, minDatBytes-1), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if !localDatFileNeedsRepair(smallPath) {
+		t.Fatal("small")
+	}
+	okPath := filepath.Join(dir, "ok.dat")
+	if err := os.WriteFile(okPath, make([]byte, minDatBytes), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if localDatFileNeedsRepair(okPath) {
+		t.Fatal("ok size")
+	}
+	dirPath := filepath.Join(dir, "isdir.dat")
+	if err := os.Mkdir(dirPath, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if !localDatFileNeedsRepair(dirPath) {
+		t.Fatal("dir should need repair")
+	}
+	if !CustomGeoLocalFileNeedsRepair(dirPath) {
+		t.Fatal("exported wrapper dir")
+	}
+	if CustomGeoLocalFileNeedsRepair(okPath) {
+		t.Fatal("exported wrapper ok file")
+	}
+}
+
+func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
+	disableSSRFCheck(t)
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodHead {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer ts.Close()
+	if err := probeCustomGeoURL(ts.URL); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
+	disableSSRFCheck(t)
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodHead {
+			w.WriteHeader(http.StatusMethodNotAllowed)
+			return
+		}
+		if r.Method == http.MethodGet && r.Header.Get("Range") != "" {
+			w.WriteHeader(http.StatusPartialContent)
+			_, _ = w.Write([]byte{0})
+			return
+		}
+		w.WriteHeader(http.StatusBadRequest)
+	}))
+	defer ts.Close()
+	if err := probeCustomGeoURL(ts.URL); err != nil {
+		t.Fatal(err)
+	}
+}

+ 10 - 0
web/service/inbound.go

@@ -1891,6 +1891,16 @@ func (s *InboundService) ResetAllTraffics() error {
 	return err
 	return err
 }
 }
 
 
+func (s *InboundService) ResetInboundTraffic(id int) error {
+	db := database.GetDB()
+
+	result := db.Model(model.Inbound{}).
+		Where("id = ?", id).
+		Updates(map[string]any{"up": 0, "down": 0})
+
+	return result.Error
+}
+
 func (s *InboundService) DelDepletedClients(id int) (err error) {
 func (s *InboundService) DelDepletedClients(id int) (err error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	tx := db.Begin()
 	tx := db.Begin()

+ 145 - 0
web/service/nord.go

@@ -0,0 +1,145 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v2/util/common"
+)
+
+type NordService struct {
+	SettingService
+}
+
+var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
+
+// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
+const maxResponseSize = 10 << 20
+
+func (s *NordService) GetCountries() (string, error) {
+	resp, err := nordHTTPClient.Get("https://api.nordvpn.com/v1/countries")
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
+	if err != nil {
+		return "", err
+	}
+	return string(body), nil
+}
+
+func (s *NordService) GetServers(countryId string) (string, error) {
+	// Validate countryId is numeric to prevent URL injection
+	for _, c := range countryId {
+		if c < '0' || c > '9' {
+			return "", common.NewError("invalid country ID")
+		}
+	}
+	url := fmt.Sprintf("https://api.nordvpn.com/v2/servers?limit=0&filters[servers_technologies][id]=35&filters[country_id]=%s", countryId)
+	resp, err := nordHTTPClient.Get(url)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
+	if err != nil {
+		return "", err
+	}
+	var data map[string]any
+	if err := json.Unmarshal(body, &data); err != nil {
+		return string(body), nil
+	}
+
+	servers, ok := data["servers"].([]any)
+	if !ok {
+		return string(body), nil
+	}
+
+	var filtered []any
+	for _, s := range servers {
+		if server, ok := s.(map[string]any); ok {
+			if load, ok := server["load"].(float64); ok && load > 7 {
+				filtered = append(filtered, s)
+			}
+		}
+	}
+	data["servers"] = filtered
+
+	result, _ := json.Marshal(data)
+	return string(result), nil
+}
+
+func (s *NordService) SetKey(privateKey string) (string, error) {
+	if privateKey == "" {
+		return "", common.NewError("private key cannot be empty")
+	}
+	nordData := map[string]string{
+		"private_key": privateKey,
+		"token":       "",
+	}
+	data, _ := json.Marshal(nordData)
+	err := s.SettingService.SetNord(string(data))
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func (s *NordService) GetCredentials(token string) (string, error) {
+	url := "https://api.nordvpn.com/v1/users/services/credentials"
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return "", err
+	}
+	req.SetBasicAuth("token", token)
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
+	}
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
+	if err != nil {
+		return "", err
+	}
+
+	var creds map[string]any
+	if err := json.Unmarshal(body, &creds); err != nil {
+		return "", err
+	}
+
+	privateKey, ok := creds["nordlynx_private_key"].(string)
+	if !ok || privateKey == "" {
+		return "", common.NewError("failed to retrieve NordLynx private key")
+	}
+
+	nordData := map[string]string{
+		"private_key": privateKey,
+		"token":       token,
+	}
+	data, _ := json.Marshal(nordData)
+	err = s.SettingService.SetNord(string(data))
+	if err != nil {
+		return "", err
+	}
+
+	return string(data), nil
+}
+
+func (s *NordService) GetNordData() (string, error) {
+	return s.SettingService.GetNord()
+}
+
+func (s *NordService) DelNordData() error {
+	return s.SettingService.SetNord("")
+}

+ 56 - 0
web/service/server.go

@@ -34,6 +34,8 @@ import (
 	"github.com/shirou/gopsutil/v4/load"
 	"github.com/shirou/gopsutil/v4/load"
 	"github.com/shirou/gopsutil/v4/mem"
 	"github.com/shirou/gopsutil/v4/mem"
 	"github.com/shirou/gopsutil/v4/net"
 	"github.com/shirou/gopsutil/v4/net"
+	"github.com/xtls/xray-core/app/router"
+	"google.golang.org/protobuf/proto"
 )
 )
 
 
 // ProcessState represents the current state of a system process.
 // ProcessState represents the current state of a system process.
@@ -1055,6 +1057,48 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
 	return matched
 	return matched
 }
 }
 
 
+// NormalizeGeositeCountryCodes reads a geosite .dat file, uppercases all
+// country_code fields, and writes it back. This works around a case-sensitivity
+// mismatch in Xray-core: the router normalizes codes to uppercase before lookup,
+// but the find() function compares bytes case-sensitively. Some geosite.dat
+// providers (e.g. Loyalsoldier) store codes in lowercase, causing lookup failures.
+func NormalizeGeositeCountryCodes(path string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("failed to read geosite file %s: %w", path, err)
+	}
+
+	var list router.GeoSiteList
+	if err := proto.Unmarshal(data, &list); err != nil {
+		return fmt.Errorf("failed to parse geosite file %s: %w", path, err)
+	}
+
+	changed := false
+	for _, entry := range list.Entry {
+		upper := strings.ToUpper(entry.CountryCode)
+		if entry.CountryCode != upper {
+			entry.CountryCode = upper
+			changed = true
+		}
+	}
+
+	if !changed {
+		return nil
+	}
+
+	normalized, err := proto.Marshal(&list)
+	if err != nil {
+		return fmt.Errorf("failed to serialize normalized geosite file %s: %w", path, err)
+	}
+
+	if err := os.WriteFile(path, normalized, 0o644); err != nil {
+		return fmt.Errorf("failed to write normalized geosite file %s: %w", path, err)
+	}
+
+	logger.Infof("Normalized country codes to uppercase in %s (%d entries)", path, len(list.Entry))
+	return nil
+}
+
 func (s *ServerService) UpdateGeofile(fileName string) error {
 func (s *ServerService) UpdateGeofile(fileName string) error {
 	type geofileEntry struct {
 	type geofileEntry struct {
 		URL      string
 		URL      string
@@ -1146,12 +1190,22 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 
 
 	var errorMessages []string
 	var errorMessages []string
 
 
+	normalizeIfGeosite := func(destPath, name string) {
+		if strings.Contains(name, "geosite") {
+			if err := NormalizeGeositeCountryCodes(destPath); err != nil {
+				logger.Warningf("Failed to normalize geosite country codes in %s: %v", name, err)
+			}
+		}
+	}
+
 	if fileName == "" {
 	if fileName == "" {
 		// Download all geofiles
 		// Download all geofiles
 		for _, entry := range geofileAllowlist {
 		for _, entry := range geofileAllowlist {
 			destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
 			destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
 			if err := downloadFile(entry.URL, destPath); err != nil {
 			if err := downloadFile(entry.URL, destPath); err != nil {
 				errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
 				errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
+			} else {
+				normalizeIfGeosite(destPath, entry.FileName)
 			}
 			}
 		}
 		}
 	} else {
 	} else {
@@ -1159,6 +1213,8 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 		destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
 		destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
 		if err := downloadFile(entry.URL, destPath); err != nil {
 		if err := downloadFile(entry.URL, destPath); err != nil {
 			errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
 			errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
+		} else {
+			normalizeIfGeosite(destPath, entry.FileName)
 		}
 		}
 	}
 	}
 
 

+ 51 - 15
web/service/setting.go

@@ -71,12 +71,16 @@ var defaultValueMap = map[string]string{
 	"subURI":                      "",
 	"subURI":                      "",
 	"subJsonPath":                 "/json/",
 	"subJsonPath":                 "/json/",
 	"subJsonURI":                  "",
 	"subJsonURI":                  "",
+	"subClashEnable":              "true",
+	"subClashPath":                "/clash/",
+	"subClashURI":                 "",
 	"subJsonFragment":             "",
 	"subJsonFragment":             "",
 	"subJsonNoises":               "",
 	"subJsonNoises":               "",
 	"subJsonMux":                  "",
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
 	"subJsonRules":                "",
 	"datepicker":                  "gregorian",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
 	"warp":                        "",
+	"nord":                        "",
 	"externalTrafficInformEnable": "false",
 	"externalTrafficInformEnable": "false",
 	"externalTrafficInformURI":    "",
 	"externalTrafficInformURI":    "",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
@@ -555,6 +559,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
 	return s.getString("subJsonURI")
 	return s.getString("subJsonURI")
 }
 }
 
 
+func (s *SettingService) GetSubClashEnable() (bool, error) {
+	return s.getBool("subClashEnable")
+}
+
+func (s *SettingService) GetSubClashPath() (string, error) {
+	return s.getString("subClashPath")
+}
+
+func (s *SettingService) GetSubClashURI() (string, error) {
+	return s.getString("subClashURI")
+}
+
 func (s *SettingService) GetSubJsonFragment() (string, error) {
 func (s *SettingService) GetSubJsonFragment() (string, error) {
 	return s.getString("subJsonFragment")
 	return s.getString("subJsonFragment")
 }
 }
@@ -583,6 +599,14 @@ func (s *SettingService) SetWarp(data string) error {
 	return s.setString("warp", data)
 	return s.setString("warp", data)
 }
 }
 
 
+func (s *SettingService) GetNord() (string, error) {
+	return s.getString("nord")
+}
+
+func (s *SettingService) SetNord(data string) error {
+	return s.setString("nord", data)
+}
+
 func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
 func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
 	return s.getBool("externalTrafficInformEnable")
 	return s.getBool("externalTrafficInformEnable")
 }
 }
@@ -743,20 +767,22 @@ func extractHostname(host string) string {
 func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 	type settingFunc func() (any, error)
 	type settingFunc func() (any, error)
 	settings := map[string]settingFunc{
 	settings := map[string]settingFunc{
-		"expireDiff":    func() (any, error) { return s.GetExpireDiff() },
-		"trafficDiff":   func() (any, error) { return s.GetTrafficDiff() },
-		"pageSize":      func() (any, error) { return s.GetPageSize() },
-		"defaultCert":   func() (any, error) { return s.GetCertFile() },
-		"defaultKey":    func() (any, error) { return s.GetKeyFile() },
-		"tgBotEnable":   func() (any, error) { return s.GetTgbotEnabled() },
-		"subEnable":     func() (any, error) { return s.GetSubEnable() },
-		"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
-		"subTitle":      func() (any, error) { return s.GetSubTitle() },
-		"subURI":        func() (any, error) { return s.GetSubURI() },
-		"subJsonURI":    func() (any, error) { return s.GetSubJsonURI() },
-		"remarkModel":   func() (any, error) { return s.GetRemarkModel() },
-		"datepicker":    func() (any, error) { return s.GetDatepicker() },
-		"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
+		"expireDiff":     func() (any, error) { return s.GetExpireDiff() },
+		"trafficDiff":    func() (any, error) { return s.GetTrafficDiff() },
+		"pageSize":       func() (any, error) { return s.GetPageSize() },
+		"defaultCert":    func() (any, error) { return s.GetCertFile() },
+		"defaultKey":     func() (any, error) { return s.GetKeyFile() },
+		"tgBotEnable":    func() (any, error) { return s.GetTgbotEnabled() },
+		"subEnable":      func() (any, error) { return s.GetSubEnable() },
+		"subJsonEnable":  func() (any, error) { return s.GetSubJsonEnable() },
+		"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
+		"subTitle":       func() (any, error) { return s.GetSubTitle() },
+		"subURI":         func() (any, error) { return s.GetSubURI() },
+		"subJsonURI":     func() (any, error) { return s.GetSubJsonURI() },
+		"subClashURI":    func() (any, error) { return s.GetSubClashURI() },
+		"remarkModel":    func() (any, error) { return s.GetRemarkModel() },
+		"datepicker":     func() (any, error) { return s.GetDatepicker() },
+		"ipLimitEnable":  func() (any, error) { return s.GetIpLimitEnable() },
 	}
 	}
 
 
 	result := make(map[string]any)
 	result := make(map[string]any)
@@ -776,12 +802,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 			subJsonEnable = b
 			subJsonEnable = b
 		}
 		}
 	}
 	}
-	if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
+	subClashEnable := false
+	if v, ok := result["subClashEnable"]; ok {
+		if b, ok2 := v.(bool); ok2 {
+			subClashEnable = b
+		}
+	}
+	if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
 		subURI := ""
 		subURI := ""
 		subTitle, _ := s.GetSubTitle()
 		subTitle, _ := s.GetSubTitle()
 		subPort, _ := s.GetSubPort()
 		subPort, _ := s.GetSubPort()
 		subPath, _ := s.GetSubPath()
 		subPath, _ := s.GetSubPath()
 		subJsonPath, _ := s.GetSubJsonPath()
 		subJsonPath, _ := s.GetSubJsonPath()
+		subClashPath, _ := s.GetSubClashPath()
 		subDomain, _ := s.GetSubDomain()
 		subDomain, _ := s.GetSubDomain()
 		subKeyFile, _ := s.GetSubKeyFile()
 		subKeyFile, _ := s.GetSubKeyFile()
 		subCertFile, _ := s.GetSubCertFile()
 		subCertFile, _ := s.GetSubCertFile()
@@ -811,6 +844,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		if subJsonEnable && result["subJsonURI"].(string) == "" {
 		if subJsonEnable && result["subJsonURI"].(string) == "" {
 			result["subJsonURI"] = subURI + subJsonPath
 			result["subJsonURI"] = subURI + subJsonPath
 		}
 		}
+		if subClashEnable && result["subClashURI"].(string) == "" {
+			result["subClashURI"] = subURI + subClashPath
+		}
 	}
 	}
 
 
 	return result, nil
 	return result, nil

+ 22 - 18
web/service/xray.go

@@ -118,31 +118,35 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		json.Unmarshal([]byte(inbound.Settings), &settings)
 		json.Unmarshal([]byte(inbound.Settings), &settings)
 		clients, ok := settings["clients"].([]any)
 		clients, ok := settings["clients"].([]any)
 		if ok {
 		if ok {
-			// check users active or not
+			// Fast O(N) lookup map for client traffic enablement
 			clientStats := inbound.ClientStats
 			clientStats := inbound.ClientStats
+			enableMap := make(map[string]bool, len(clientStats))
 			for _, clientTraffic := range clientStats {
 			for _, clientTraffic := range clientStats {
-				indexDecrease := 0
-				for index, client := range clients {
-					c := client.(map[string]any)
-					if c["email"] == clientTraffic.Email {
-						if !clientTraffic.Enable {
-							clients = RemoveIndex(clients, index-indexDecrease)
-							indexDecrease++
-							logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c["email"])
-						}
-					}
-				}
+				enableMap[clientTraffic.Email] = clientTraffic.Enable
 			}
 			}
 
 
-			// clear client config for additional parameters
+			// filter and clean clients
 			var final_clients []any
 			var final_clients []any
 			for _, client := range clients {
 			for _, client := range clients {
-				c := client.(map[string]any)
-				if c["enable"] != nil {
-					if enable, ok := c["enable"].(bool); ok && !enable {
-						continue
-					}
+				c, ok := client.(map[string]any)
+				if !ok {
+					continue
 				}
 				}
+
+				email, _ := c["email"].(string)
+
+				// check users active or not via stats
+				if enable, exists := enableMap[email]; exists && !enable {
+					logger.Infof("Remove Inbound User %s due to expiration or traffic limit", email)
+					continue
+				}
+
+				// check manual disabled flag
+				if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
+					continue
+				}
+
+				// clear client config for additional parameters
 				for key := range c {
 				for key := range c {
 					if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
 					if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
 						delete(c, key)
 						delete(c, key)

+ 54 - 0
web/translation/translate.ar_EG.toml

@@ -4,6 +4,8 @@
 "confirm" = "تأكيد"
 "confirm" = "تأكيد"
 "cancel" = "إلغاء"
 "cancel" = "إلغاء"
 "close" = "إغلاق"
 "close" = "إغلاق"
+"save" = "حفظ"
+"logout" = "تسجيل خروج"
 "create" = "إنشاء"
 "create" = "إنشاء"
 "update" = "تحديث"
 "update" = "تحديث"
 "copy" = "نسخ"
 "copy" = "نسخ"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
 "readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
 "getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
 "getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
 "getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
 "getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
+"customGeoTitle" = "GeoSite / GeoIP مخصص"
+"customGeoAdd" = "إضافة"
+"customGeoType" = "النوع"
+"customGeoAlias" = "الاسم المستعار"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "مفعّل"
+"customGeoLastUpdated" = "آخر تحديث"
+"customGeoExtColumn" = "التوجيه (ext:…)"
+"customGeoToastUpdateAll" = "تم تحديث جميع المصادر المخصصة"
+"customGeoActions" = "إجراءات"
+"customGeoEdit" = "تعديل"
+"customGeoDelete" = "حذف"
+"customGeoDownload" = "تحديث الآن"
+"customGeoModalAdd" = "إضافة geo مخصص"
+"customGeoModalEdit" = "تعديل geo مخصص"
+"customGeoModalSave" = "حفظ"
+"customGeoDeleteConfirm" = "حذف مصدر geo المخصص هذا؟"
+"customGeoRoutingHint" = "في قواعد التوجيه استخدم العمود كـ ext:file.dat:tag (استبدل tag)."
+"customGeoInvalidId" = "معرّف المورد غير صالح"
+"customGeoAliasesError" = "تعذّر تحميل أسماء geo المخصصة"
+"customGeoValidationAlias" = "الاسم المستعار: أحرف صغيرة وأرقام و - و _ فقط"
+"customGeoValidationUrl" = "يجب أن يبدأ الرابط بـ http:// أو https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (مخصص)"
+"customGeoToastList" = "قائمة geo المخصص"
+"customGeoToastAdd" = "إضافة geo مخصص"
+"customGeoToastUpdate" = "تحديث geo مخصص"
+"customGeoToastDelete" = "تم حذف geofile «{{ .fileName }}» المخصص"
+"customGeoToastDownload" = "تم تحديث geofile «{{ .fileName }}»"
+"customGeoErrInvalidType" = "يجب أن يكون النوع geosite أو geoip"
+"customGeoErrAliasRequired" = "الاسم المستعار مطلوب"
+"customGeoErrAliasPattern" = "الاسم المستعار يحتوي على أحرف غير مسموحة"
+"customGeoErrAliasReserved" = "هذا الاسم محجوز"
+"customGeoErrUrlRequired" = "الرابط مطلوب"
+"customGeoErrInvalidUrl" = "الرابط غير صالح"
+"customGeoErrUrlScheme" = "يجب أن يستخدم الرابط http أو https"
+"customGeoErrUrlHost" = "مضيف الرابط غير صالح"
+"customGeoErrDuplicateAlias" = "هذا الاسم مستخدم مسبقاً لهذا النوع"
+"customGeoErrNotFound" = "مصدر geo المخصص غير موجود"
+"customGeoErrDownload" = "فشل التنزيل"
+"customGeoErrUpdateAllIncomplete" = "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "إجمالي حركة المرور"
 "allTimeTraffic" = "إجمالي حركة المرور"
@@ -271,6 +314,7 @@
 "daily" = "يومياً"
 "daily" = "يومياً"
 "weekly" = "أسبوعياً"
 "weekly" = "أسبوعياً"
 "monthly" = "شهرياً"
 "monthly" = "شهرياً"
+"hourly" = "كل ساعة"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "تم الحصول عليه"
 "obtain" = "تم الحصول عليه"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر IPv4."
 "ipv4RoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر IPv4."
 "warpRouting" = "توجيه WARP"
 "warpRouting" = "توجيه WARP"
 "warpRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر WARP."
 "warpRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر WARP."
+"nordRouting" = "توجيه NordVPN"
+"nordRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر NordVPN."
 "Template" = "قالب إعدادات Xray المتقدم"
 "Template" = "قالب إعدادات Xray المتقدم"
 "TemplateDesc" = "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده."
 "TemplateDesc" = "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده."
 "FreedomStrategy" = "استراتيجية بروتوكول الحرية"
 "FreedomStrategy" = "استراتيجية بروتوكول الحرية"
@@ -531,6 +577,14 @@
 "testSuccess" = "الاختبار ناجح"
 "testSuccess" = "الاختبار ناجح"
 "testFailed" = "فشل الاختبار"
 "testFailed" = "فشل الاختبار"
 "testError" = "فشل اختبار المخرج"
 "testError" = "فشل اختبار المخرج"
+"nordvpn" = "NordVPN"
+"accessToken" = "رمز الوصول"
+"country" = "الدولة"
+"server" = "الخادم"
+"city" = "المدينة"
+"allCities" = "كل المدن"
+"privateKey" = "المفتاح الخاص"
+"load" = "الحمل"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "أضف موازن تحميل"
 "addBalancer" = "أضف موازن تحميل"

+ 54 - 0
web/translation/translate.en_US.toml

@@ -4,6 +4,8 @@
 "confirm" = "Confirm"
 "confirm" = "Confirm"
 "cancel" = "Cancel"
 "cancel" = "Cancel"
 "close" = "Close"
 "close" = "Close"
+"save" = "Save"
+"logout" = "Log Out"
 "create" = "Create"
 "create" = "Create"
 "update" = "Update"
 "update" = "Update"
 "copy" = "Copy"
 "copy" = "Copy"
@@ -150,6 +152,47 @@
 "geofilesUpdateDialogDesc" = "This will update all geofiles."
 "geofilesUpdateDialogDesc" = "This will update all geofiles."
 "geofilesUpdateAll" = "Update all"
 "geofilesUpdateAll" = "Update all"
 "geofileUpdatePopover" = "Geofile updated successfully"
 "geofileUpdatePopover" = "Geofile updated successfully"
+"customGeoTitle" = "Custom GeoSite / GeoIP"
+"customGeoAdd" = "Add"
+"customGeoType" = "Type"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Enabled"
+"customGeoLastUpdated" = "Last updated"
+"customGeoExtColumn" = "Routing (ext:…)"
+"customGeoToastUpdateAll" = "All custom geo sources updated"
+"customGeoActions" = "Actions"
+"customGeoEdit" = "Edit"
+"customGeoDelete" = "Delete"
+"customGeoDownload" = "Update now"
+"customGeoModalAdd" = "Add custom geo"
+"customGeoModalEdit" = "Edit custom geo"
+"customGeoModalSave" = "Save"
+"customGeoDeleteConfirm" = "Delete this custom geo source?"
+"customGeoRoutingHint" = "In routing rules use the value column as ext:file.dat:tag (replace tag)."
+"customGeoInvalidId" = "Invalid resource id"
+"customGeoAliasesError" = "Failed to load custom geo aliases"
+"customGeoValidationAlias" = "Alias may only contain lowercase letters, digits, - and _"
+"customGeoValidationUrl" = "URL must start with http:// or https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (custom)"
+"customGeoToastList" = "Custom geo list"
+"customGeoToastAdd" = "Add custom geo"
+"customGeoToastUpdate" = "Update custom geo"
+"customGeoToastDelete" = "Custom geo file “{{ .fileName }}” deleted"
+"customGeoToastDownload" = "Geofile “{{ .fileName }}” updated"
+"customGeoErrInvalidType" = "Type must be geosite or geoip"
+"customGeoErrAliasRequired" = "Alias is required"
+"customGeoErrAliasPattern" = "Alias must match allowed characters"
+"customGeoErrAliasReserved" = "This alias is reserved"
+"customGeoErrUrlRequired" = "URL is required"
+"customGeoErrInvalidUrl" = "URL is invalid"
+"customGeoErrUrlScheme" = "URL must use http or https"
+"customGeoErrUrlHost" = "URL host is invalid"
+"customGeoErrDuplicateAlias" = "This alias is already used for this type"
+"customGeoErrNotFound" = "Custom geo source not found"
+"customGeoErrDownload" = "Download failed"
+"customGeoErrUpdateAllIncomplete" = "One or more custom geo sources failed to update"
 "dontRefresh" = "Installation is in progress, please do not refresh this page"
 "dontRefresh" = "Installation is in progress, please do not refresh this page"
 "logs" = "Logs"
 "logs" = "Logs"
 "config" = "Config"
 "config" = "Config"
@@ -271,6 +314,7 @@
 "daily" = "Daily"
 "daily" = "Daily"
 "weekly" = "Weekly"
 "weekly" = "Weekly"
 "monthly" = "Monthly"
 "monthly" = "Monthly"
+"hourly" = "Hourly"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Obtain"
 "obtain" = "Obtain"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "These options will route traffic based on a specific destination via IPv4."
 "ipv4RoutingDesc" = "These options will route traffic based on a specific destination via IPv4."
 "warpRouting" = "WARP Routing"
 "warpRouting" = "WARP Routing"
 "warpRoutingDesc" = "These options will route traffic based on a specific destination via WARP."
 "warpRoutingDesc" = "These options will route traffic based on a specific destination via WARP."
+"nordRouting" = "NordVPN Routing"
+"nordRoutingDesc" = "These options will route traffic based on a specific destination via NordVPN."
 "Template" = "Advanced Xray Configuration Template"
 "Template" = "Advanced Xray Configuration Template"
 "TemplateDesc" = "The final Xray config file will be generated based on this template."
 "TemplateDesc" = "The final Xray config file will be generated based on this template."
 "FreedomStrategy" = "Freedom Protocol Strategy"
 "FreedomStrategy" = "Freedom Protocol Strategy"
@@ -531,6 +577,14 @@
 "testSuccess" = "Test successful"
 "testSuccess" = "Test successful"
 "testFailed" = "Test failed"
 "testFailed" = "Test failed"
 "testError" = "Failed to test outbound"
 "testError" = "Failed to test outbound"
+"nordvpn" = "NordVPN"
+"accessToken" = "Access Token"
+"country" = "Country"
+"server" = "Server"
+"city" = "City"
+"allCities" = "All Cities"
+"privateKey" = "Private Key"
+"load" = "Load"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Add Balancer"
 "addBalancer" = "Add Balancer"

+ 54 - 0
web/translation/translate.es_ES.toml

@@ -4,6 +4,8 @@
 "confirm" = "Confirmar"
 "confirm" = "Confirmar"
 "cancel" = "Cancelar"
 "cancel" = "Cancelar"
 "close" = "Cerrar"
 "close" = "Cerrar"
+"save" = "Guardar"
+"logout" = "Cerrar Sesión"
 "create" = "Crear"
 "create" = "Crear"
 "update" = "Actualizar"
 "update" = "Actualizar"
 "copy" = "Copiar"
 "copy" = "Copiar"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "Ocurrió un error al leer la base de datos"
 "readDatabaseError" = "Ocurrió un error al leer la base de datos"
 "getDatabaseError" = "Ocurrió un error al obtener la base de datos"
 "getDatabaseError" = "Ocurrió un error al obtener la base de datos"
 "getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
 "getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
+"customGeoTitle" = "GeoSite / GeoIP personalizados"
+"customGeoAdd" = "Añadir"
+"customGeoType" = "Tipo"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Activado"
+"customGeoLastUpdated" = "Última actualización"
+"customGeoExtColumn" = "Enrutamiento (ext:…)"
+"customGeoToastUpdateAll" = "Todas las fuentes personalizadas se actualizaron"
+"customGeoActions" = "Acciones"
+"customGeoEdit" = "Editar"
+"customGeoDelete" = "Eliminar"
+"customGeoDownload" = "Actualizar ahora"
+"customGeoModalAdd" = "Añadir geo personalizado"
+"customGeoModalEdit" = "Editar geo personalizado"
+"customGeoModalSave" = "Guardar"
+"customGeoDeleteConfirm" = "¿Eliminar esta fuente geo personalizada?"
+"customGeoRoutingHint" = "En reglas de enrutamiento use la columna de valor como ext:archivo.dat:etiqueta (sustituya la etiqueta)."
+"customGeoInvalidId" = "Id de recurso no válido"
+"customGeoAliasesError" = "No se pudieron cargar los alias geo personalizados"
+"customGeoValidationAlias" = "El alias solo puede contener letras minúsculas, dígitos, - y _"
+"customGeoValidationUrl" = "La URL debe comenzar con http:// o https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (personalizado)"
+"customGeoToastList" = "Lista de geo personalizado"
+"customGeoToastAdd" = "Añadir geo personalizado"
+"customGeoToastUpdate" = "Actualizar geo personalizado"
+"customGeoToastDelete" = "Geofile personalizado «{{ .fileName }}» eliminado"
+"customGeoToastDownload" = "Geofile «{{ .fileName }}» actualizado"
+"customGeoErrInvalidType" = "El tipo debe ser geosite o geoip"
+"customGeoErrAliasRequired" = "El alias es obligatorio"
+"customGeoErrAliasPattern" = "El alias contiene caracteres no permitidos"
+"customGeoErrAliasReserved" = "Este alias está reservado"
+"customGeoErrUrlRequired" = "La URL es obligatoria"
+"customGeoErrInvalidUrl" = "La URL no es válida"
+"customGeoErrUrlScheme" = "La URL debe usar http o https"
+"customGeoErrUrlHost" = "El host de la URL no es válido"
+"customGeoErrDuplicateAlias" = "Este alias ya se usa para este tipo"
+"customGeoErrNotFound" = "Fuente geo personalizada no encontrada"
+"customGeoErrDownload" = "Error de descarga"
+"customGeoErrUpdateAllIncomplete" = "No se pudieron actualizar una o más fuentes geo personalizadas"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "Tráfico Total"
 "allTimeTraffic" = "Tráfico Total"
@@ -271,6 +314,7 @@
 "daily" = "Diariamente"
 "daily" = "Diariamente"
 "weekly" = "Semanalmente"
 "weekly" = "Semanalmente"
 "monthly" = "Mensualmente"
 "monthly" = "Mensualmente"
+"hourly" = "Cada hora"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Recibir"
 "obtain" = "Recibir"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "Estas opciones solo enrutarán a los dominios objetivo a través de IPv4."
 "ipv4RoutingDesc" = "Estas opciones solo enrutarán a los dominios objetivo a través de IPv4."
 "warpRouting" = "Enrutamiento WARP"
 "warpRouting" = "Enrutamiento WARP"
 "warpRoutingDesc" = "Precaución: Antes de usar estas opciones, instale WARP en modo de proxy socks5 en su servidor siguiendo los pasos en el GitHub del panel. WARP enrutará el tráfico a los sitios web a través de los servidores de Cloudflare."
 "warpRoutingDesc" = "Precaución: Antes de usar estas opciones, instale WARP en modo de proxy socks5 en su servidor siguiendo los pasos en el GitHub del panel. WARP enrutará el tráfico a los sitios web a través de los servidores de Cloudflare."
+"nordRouting" = "Enrutamiento NordVPN"
+"nordRoutingDesc" = "Estas opciones enrutarán el tráfico basado en un destino específico a través de NordVPN."
 "Template" = "Plantilla de Configuración de Xray"
 "Template" = "Plantilla de Configuración de Xray"
 "TemplateDesc" = "Genera el archivo de configuración final de Xray basado en esta plantilla."
 "TemplateDesc" = "Genera el archivo de configuración final de Xray basado en esta plantilla."
 "FreedomStrategy" = "Configurar Estrategia para el Protocolo Freedom"
 "FreedomStrategy" = "Configurar Estrategia para el Protocolo Freedom"
@@ -531,6 +577,14 @@
 "testSuccess" = "Prueba exitosa"
 "testSuccess" = "Prueba exitosa"
 "testFailed" = "Prueba fallida"
 "testFailed" = "Prueba fallida"
 "testError" = "Error al probar la salida"
 "testError" = "Error al probar la salida"
+"nordvpn" = "NordVPN"
+"accessToken" = "Token de acceso"
+"country" = "País"
+"server" = "Servidor"
+"city" = "Ciudad"
+"allCities" = "Todas las ciudades"
+"privateKey" = "Clave privada"
+"load" = "Carga"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Agregar equilibrador"
 "addBalancer" = "Agregar equilibrador"

+ 54 - 0
web/translation/translate.fa_IR.toml

@@ -4,6 +4,8 @@
 "confirm" = "تایید"
 "confirm" = "تایید"
 "cancel" = "انصراف"
 "cancel" = "انصراف"
 "close" = "بستن"
 "close" = "بستن"
+"save" = "ذخیره"
+"logout" = "خروج"
 "create" = "ایجاد"
 "create" = "ایجاد"
 "update" = "به‌روزرسانی"
 "update" = "به‌روزرسانی"
 "copy" = "کپی"
 "copy" = "کپی"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "خطا در خواندن پایگاه داده"
 "readDatabaseError" = "خطا در خواندن پایگاه داده"
 "getDatabaseError" = "خطا در دریافت پایگاه داده"
 "getDatabaseError" = "خطا در دریافت پایگاه داده"
 "getConfigError" = "خطا در دریافت فایل پیکربندی"
 "getConfigError" = "خطا در دریافت فایل پیکربندی"
+"customGeoTitle" = "GeoSite / GeoIP سفارشی"
+"customGeoAdd" = "افزودن"
+"customGeoType" = "نوع"
+"customGeoAlias" = "نام مستعار"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "فعال"
+"customGeoLastUpdated" = "آخرین به‌روزرسانی"
+"customGeoExtColumn" = "مسیریابی (ext:…)"
+"customGeoToastUpdateAll" = "همه منابع سفارشی به‌روزرسانی شدند"
+"customGeoActions" = "اقدامات"
+"customGeoEdit" = "ویرایش"
+"customGeoDelete" = "حذف"
+"customGeoDownload" = "به‌روزرسانی اکنون"
+"customGeoModalAdd" = "افزودن geo سفارشی"
+"customGeoModalEdit" = "ویرایش geo سفارشی"
+"customGeoModalSave" = "ذخیره"
+"customGeoDeleteConfirm" = "این منبع geo سفارشی حذف شود؟"
+"customGeoRoutingHint" = "در قوانین مسیریابی مقدار را به صورت ext:file.dat:tag استفاده کنید (tag را جایگزین کنید)."
+"customGeoInvalidId" = "شناسه منبع نامعتبر است"
+"customGeoAliasesError" = "بارگذاری نام مستعارهای geo سفارشی ناموفق بود"
+"customGeoValidationAlias" = "نام مستعار فقط حروف کوچک، اعداد، - و _"
+"customGeoValidationUrl" = "URL باید با http:// یا https:// شروع شود"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (سفارشی)"
+"customGeoToastList" = "فهرست geo سفارشی"
+"customGeoToastAdd" = "افزودن geo سفارشی"
+"customGeoToastUpdate" = "به‌روزرسانی geo سفارشی"
+"customGeoToastDelete" = "geofile سفارشی «{{ .fileName }}» حذف شد"
+"customGeoToastDownload" = "geofile «{{ .fileName }}» به‌روزرسانی شد"
+"customGeoErrInvalidType" = "نوع باید geosite یا geoip باشد"
+"customGeoErrAliasRequired" = "نام مستعار لازم است"
+"customGeoErrAliasPattern" = "نام مستعار دارای نویسه نامجاز است"
+"customGeoErrAliasReserved" = "این نام مستعار رزرو است"
+"customGeoErrUrlRequired" = "URL لازم است"
+"customGeoErrInvalidUrl" = "URL نامعتبر است"
+"customGeoErrUrlScheme" = "URL باید http یا https باشد"
+"customGeoErrUrlHost" = "میزبان URL نامعتبر است"
+"customGeoErrDuplicateAlias" = "این نام مستعار برای این نوع قبلاً استفاده شده است"
+"customGeoErrNotFound" = "منبع geo سفارشی یافت نشد"
+"customGeoErrDownload" = "بارگیری ناموفق بود"
+"customGeoErrUpdateAllIncomplete" = "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "کل ترافیک"
 "allTimeTraffic" = "کل ترافیک"
@@ -271,6 +314,7 @@
 "daily" = "روزانه"
 "daily" = "روزانه"
 "weekly" = "هفتگی"
 "weekly" = "هفتگی"
 "monthly" = "ماهانه"
 "monthly" = "ماهانه"
+"hourly" = "هر ساعت"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "فراهم‌سازی"
 "obtain" = "فراهم‌سازی"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "این گزینه‌ها ترافیک را از طریق آی‌پی نسخه4 سرور، به مقصد هدایت می‌کند"
 "ipv4RoutingDesc" = "این گزینه‌ها ترافیک را از طریق آی‌پی نسخه4 سرور، به مقصد هدایت می‌کند"
 "warpRouting" = "WARP مسیریابی"
 "warpRouting" = "WARP مسیریابی"
 "warpRoutingDesc" = "این گزینه‌ها ترافیک‌ را از طریق وارپ کلادفلر به مقصد هدایت می‌کند"
 "warpRoutingDesc" = "این گزینه‌ها ترافیک‌ را از طریق وارپ کلادفلر به مقصد هدایت می‌کند"
+"nordRouting" = "مسیریابی NordVPN"
+"nordRoutingDesc" = "این گزینه‌ها ترافیک را بر اساس مقصد خاص از طریق NordVPN مسیریابی می‌کنند."
 "Template" = "‌پیکربندی پیشرفته الگو ایکس‌ری"
 "Template" = "‌پیکربندی پیشرفته الگو ایکس‌ری"
 "TemplateDesc" = "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود"
 "TemplateDesc" = "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود"
 "FreedomStrategy" = "Freedom استراتژی پروتکل"
 "FreedomStrategy" = "Freedom استراتژی پروتکل"
@@ -531,6 +577,14 @@
 "testSuccess" = "تست موفقیت‌آمیز"
 "testSuccess" = "تست موفقیت‌آمیز"
 "testFailed" = "تست ناموفق"
 "testFailed" = "تست ناموفق"
 "testError" = "خطا در تست خروجی"
 "testError" = "خطا در تست خروجی"
+"nordvpn" = "NordVPN"
+"accessToken" = "توکن دسترسی"
+"country" = "کشور"
+"server" = "سرور"
+"privateKey" = "کلید خصوصی"
+"city" = "شهر"
+"allCities" = "همه شهرها"
+"load" = "فشار سرور"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "افزودن بالانسر"
 "addBalancer" = "افزودن بالانسر"

+ 54 - 0
web/translation/translate.id_ID.toml

@@ -4,6 +4,8 @@
 "confirm" = "Konfirmasi"
 "confirm" = "Konfirmasi"
 "cancel" = "Batal"
 "cancel" = "Batal"
 "close" = "Tutup"
 "close" = "Tutup"
+"save" = "Simpan"
+"logout" = "Keluar"
 "create" = "Buat"
 "create" = "Buat"
 "update" = "Perbarui"
 "update" = "Perbarui"
 "copy" = "Salin"
 "copy" = "Salin"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "Terjadi kesalahan saat membaca database"
 "readDatabaseError" = "Terjadi kesalahan saat membaca database"
 "getDatabaseError" = "Terjadi kesalahan saat mengambil database"
 "getDatabaseError" = "Terjadi kesalahan saat mengambil database"
 "getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
 "getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
+"customGeoTitle" = "GeoSite / GeoIP kustom"
+"customGeoAdd" = "Tambah"
+"customGeoType" = "Jenis"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Aktif"
+"customGeoLastUpdated" = "Terakhir diperbarui"
+"customGeoExtColumn" = "Routing (ext:…)"
+"customGeoToastUpdateAll" = "Semua sumber kustom telah diperbarui"
+"customGeoActions" = "Aksi"
+"customGeoEdit" = "Edit"
+"customGeoDelete" = "Hapus"
+"customGeoDownload" = "Perbarui sekarang"
+"customGeoModalAdd" = "Tambah geo kustom"
+"customGeoModalEdit" = "Edit geo kustom"
+"customGeoModalSave" = "Simpan"
+"customGeoDeleteConfirm" = "Hapus sumber geo kustom ini?"
+"customGeoRoutingHint" = "Pada aturan routing gunakan kolom nilai sebagai ext:file.dat:tag (ganti tag)."
+"customGeoInvalidId" = "ID sumber tidak valid"
+"customGeoAliasesError" = "Gagal memuat alias geo kustom"
+"customGeoValidationAlias" = "Alias hanya huruf kecil, angka, - dan _"
+"customGeoValidationUrl" = "URL harus diawali http:// atau https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (kustom)"
+"customGeoToastList" = "Daftar geo kustom"
+"customGeoToastAdd" = "Tambah geo kustom"
+"customGeoToastUpdate" = "Perbarui geo kustom"
+"customGeoToastDelete" = "Geofile kustom “{{ .fileName }}” dihapus"
+"customGeoToastDownload" = "Geofile “{{ .fileName }}” diperbarui"
+"customGeoErrInvalidType" = "Jenis harus geosite atau geoip"
+"customGeoErrAliasRequired" = "Alias wajib diisi"
+"customGeoErrAliasPattern" = "Alias berisi karakter yang tidak diizinkan"
+"customGeoErrAliasReserved" = "Alias ini dicadangkan"
+"customGeoErrUrlRequired" = "URL wajib diisi"
+"customGeoErrInvalidUrl" = "URL tidak valid"
+"customGeoErrUrlScheme" = "URL harus memakai http atau https"
+"customGeoErrUrlHost" = "Host URL tidak valid"
+"customGeoErrDuplicateAlias" = "Alias ini sudah dipakai untuk jenis ini"
+"customGeoErrNotFound" = "Sumber geo kustom tidak ditemukan"
+"customGeoErrDownload" = "Unduh gagal"
+"customGeoErrUpdateAllIncomplete" = "Satu atau lebih sumber geo kustom gagal diperbarui"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "Total Lalu Lintas"
 "allTimeTraffic" = "Total Lalu Lintas"
@@ -271,6 +314,7 @@
 "daily" = "Harian"
 "daily" = "Harian"
 "weekly" = "Mingguan"
 "weekly" = "Mingguan"
 "monthly" = "Bulanan"
 "monthly" = "Bulanan"
+"hourly" = "Setiap jam"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Dapatkan"
 "obtain" = "Dapatkan"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui IPv4."
 "ipv4RoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui IPv4."
 "warpRouting" = "Perutean WARP"
 "warpRouting" = "Perutean WARP"
 "warpRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui WARP."
 "warpRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui WARP."
+"nordRouting" = "Routing NordVPN"
+"nordRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui NordVPN."
 "Template" = "Template Konfigurasi Xray Lanjutan"
 "Template" = "Template Konfigurasi Xray Lanjutan"
 "TemplateDesc" = "File konfigurasi Xray akhir akan dibuat berdasarkan template ini."
 "TemplateDesc" = "File konfigurasi Xray akhir akan dibuat berdasarkan template ini."
 "FreedomStrategy" = "Strategi Protokol Freedom"
 "FreedomStrategy" = "Strategi Protokol Freedom"
@@ -531,6 +577,14 @@
 "testSuccess" = "Tes berhasil"
 "testSuccess" = "Tes berhasil"
 "testFailed" = "Tes gagal"
 "testFailed" = "Tes gagal"
 "testError" = "Gagal menguji outbound"
 "testError" = "Gagal menguji outbound"
+"nordvpn" = "NordVPN"
+"accessToken" = "Token Akses"
+"country" = "Negara"
+"server" = "Server"
+"city" = "Kota"
+"allCities" = "Semua Kota"
+"privateKey" = "Kunci Privat"
+"load" = "Beban"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Tambahkan Penyeimbang"
 "addBalancer" = "Tambahkan Penyeimbang"

+ 54 - 0
web/translation/translate.ja_JP.toml

@@ -4,6 +4,8 @@
 "confirm" = "確認"
 "confirm" = "確認"
 "cancel" = "キャンセル"
 "cancel" = "キャンセル"
 "close" = "閉じる"
 "close" = "閉じる"
+"save" = "保存"
+"logout" = "ログアウト"
 "create" = "作成"
 "create" = "作成"
 "update" = "更新"
 "update" = "更新"
 "copy" = "コピー"
 "copy" = "コピー"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
 "readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
 "getDatabaseError" = "データベースの取得中にエラーが発生しました"
 "getDatabaseError" = "データベースの取得中にエラーが発生しました"
 "getConfigError" = "設定ファイルの取得中にエラーが発生しました"
 "getConfigError" = "設定ファイルの取得中にエラーが発生しました"
+"customGeoTitle" = "カスタム GeoSite / GeoIP"
+"customGeoAdd" = "追加"
+"customGeoType" = "種類"
+"customGeoAlias" = "エイリアス"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "有効"
+"customGeoLastUpdated" = "最終更新"
+"customGeoExtColumn" = "ルーティング (ext:…)"
+"customGeoToastUpdateAll" = "すべてのカスタムソースを更新しました"
+"customGeoActions" = "操作"
+"customGeoEdit" = "編集"
+"customGeoDelete" = "削除"
+"customGeoDownload" = "今すぐ更新"
+"customGeoModalAdd" = "カスタム geo を追加"
+"customGeoModalEdit" = "カスタム geo を編集"
+"customGeoModalSave" = "保存"
+"customGeoDeleteConfirm" = "このカスタム geo ソースを削除しますか?"
+"customGeoRoutingHint" = "ルーティングでは値を ext:ファイル.dat:タグ(タグを置換)として使います。"
+"customGeoInvalidId" = "無効なリソース ID"
+"customGeoAliasesError" = "カスタム geo エイリアスの読み込みに失敗しました"
+"customGeoValidationAlias" = "エイリアスは小文字・数字・- と _ のみ使用できます"
+"customGeoValidationUrl" = "URL は http:// または https:// で始めてください"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = "(カスタム)"
+"customGeoToastList" = "カスタム geo 一覧"
+"customGeoToastAdd" = "カスタム geo を追加"
+"customGeoToastUpdate" = "カスタム geo を更新"
+"customGeoToastDelete" = "カスタム geofile「{{ .fileName }}」を削除しました"
+"customGeoToastDownload" = "geofile「{{ .fileName }}」を更新しました"
+"customGeoErrInvalidType" = "種類は geosite または geoip である必要があります"
+"customGeoErrAliasRequired" = "エイリアスが必要です"
+"customGeoErrAliasPattern" = "エイリアスに使用できない文字が含まれています"
+"customGeoErrAliasReserved" = "このエイリアスは予約されています"
+"customGeoErrUrlRequired" = "URL が必要です"
+"customGeoErrInvalidUrl" = "URL が無効です"
+"customGeoErrUrlScheme" = "URL は http または https を使用してください"
+"customGeoErrUrlHost" = "URL のホストが無効です"
+"customGeoErrDuplicateAlias" = "この種類ですでにこのエイリアスが使われています"
+"customGeoErrNotFound" = "カスタム geo ソースが見つかりません"
+"customGeoErrDownload" = "ダウンロードに失敗しました"
+"customGeoErrUpdateAllIncomplete" = "カスタム geo ソースの 1 件以上を更新できませんでした"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "総トラフィック"
 "allTimeTraffic" = "総トラフィック"
@@ -271,6 +314,7 @@
 "daily" = "毎日"
 "daily" = "毎日"
 "weekly" = "毎週"
 "weekly" = "毎週"
 "monthly" = "毎月"
 "monthly" = "毎月"
+"hourly" = "毎時"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "取得"
 "obtain" = "取得"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "このオプションはIPv4のみを介してターゲットドメインへルーティングします"
 "ipv4RoutingDesc" = "このオプションはIPv4のみを介してターゲットドメインへルーティングします"
 "warpRouting" = "WARP ルーティング"
 "warpRouting" = "WARP ルーティング"
 "warpRoutingDesc" = "注意:これらのオプションを使用する前に、パネルのGitHubの手順に従って、サーバーにsocks5プロキシモードでWARPをインストールしてください。WARPはCloudflareサーバー経由でトラフィックをウェブサイトにルーティングします。"
 "warpRoutingDesc" = "注意:これらのオプションを使用する前に、パネルのGitHubの手順に従って、サーバーにsocks5プロキシモードでWARPをインストールしてください。WARPはCloudflareサーバー経由でトラフィックをウェブサイトにルーティングします。"
+"nordRouting" = "NordVPN ルーティング"
+"nordRoutingDesc" = "これらのオプションはNordVPN経由で特定の宛先にトラフィックをルーティングします。"
 "Template" = "高度なXray設定テンプレート"
 "Template" = "高度なXray設定テンプレート"
 "TemplateDesc" = "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます"
 "TemplateDesc" = "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます"
 "FreedomStrategy" = "Freedom プロトコル戦略"
 "FreedomStrategy" = "Freedom プロトコル戦略"
@@ -531,6 +577,14 @@
 "testSuccess" = "テスト成功"
 "testSuccess" = "テスト成功"
 "testFailed" = "テスト失敗"
 "testFailed" = "テスト失敗"
 "testError" = "アウトバウンドのテストに失敗しました"
 "testError" = "アウトバウンドのテストに失敗しました"
+"nordvpn" = "NordVPN"
+"accessToken" = "アクセストークン"
+"country" = "国"
+"server" = "サーバー"
+"city" = "都市"
+"allCities" = "すべての都市"
+"privateKey" = "秘密鍵"
+"load" = "負荷"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "負荷分散追加"
 "addBalancer" = "負荷分散追加"

+ 54 - 0
web/translation/translate.pt_BR.toml

@@ -4,6 +4,8 @@
 "confirm" = "Confirmar"
 "confirm" = "Confirmar"
 "cancel" = "Cancelar"
 "cancel" = "Cancelar"
 "close" = "Fechar"
 "close" = "Fechar"
+"save" = "Salvar"
+"logout" = "Sair"
 "create" = "Criar"
 "create" = "Criar"
 "update" = "Atualizar"
 "update" = "Atualizar"
 "copy" = "Copiar"
 "copy" = "Copiar"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
 "readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
 "getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
 "getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
 "getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
 "getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
+"customGeoTitle" = "GeoSite / GeoIP personalizados"
+"customGeoAdd" = "Adicionar"
+"customGeoType" = "Tipo"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Ativado"
+"customGeoLastUpdated" = "Última atualização"
+"customGeoExtColumn" = "Roteamento (ext:…)"
+"customGeoToastUpdateAll" = "Todas as fontes personalizadas foram atualizadas"
+"customGeoActions" = "Ações"
+"customGeoEdit" = "Editar"
+"customGeoDelete" = "Excluir"
+"customGeoDownload" = "Atualizar agora"
+"customGeoModalAdd" = "Adicionar geo personalizado"
+"customGeoModalEdit" = "Editar geo personalizado"
+"customGeoModalSave" = "Salvar"
+"customGeoDeleteConfirm" = "Excluir esta fonte geo personalizada?"
+"customGeoRoutingHint" = "Nas regras de roteamento use a coluna de valor como ext:arquivo.dat:tag (substitua a tag)."
+"customGeoInvalidId" = "ID de recurso inválido"
+"customGeoAliasesError" = "Falha ao carregar aliases geo personalizados"
+"customGeoValidationAlias" = "O alias só pode conter letras minúsculas, dígitos, - e _"
+"customGeoValidationUrl" = "A URL deve começar com http:// ou https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (personalizado)"
+"customGeoToastList" = "Lista de geo personalizado"
+"customGeoToastAdd" = "Adicionar geo personalizado"
+"customGeoToastUpdate" = "Atualizar geo personalizado"
+"customGeoToastDelete" = "Geofile personalizado “{{ .fileName }}” excluído"
+"customGeoToastDownload" = "Geofile “{{ .fileName }}” atualizado"
+"customGeoErrInvalidType" = "O tipo deve ser geosite ou geoip"
+"customGeoErrAliasRequired" = "Alias é obrigatório"
+"customGeoErrAliasPattern" = "O alias contém caracteres não permitidos"
+"customGeoErrAliasReserved" = "Este alias é reservado"
+"customGeoErrUrlRequired" = "URL é obrigatória"
+"customGeoErrInvalidUrl" = "URL inválida"
+"customGeoErrUrlScheme" = "A URL deve usar http ou https"
+"customGeoErrUrlHost" = "Host da URL inválido"
+"customGeoErrDuplicateAlias" = "Este alias já está em uso para este tipo"
+"customGeoErrNotFound" = "Fonte geo personalizada não encontrada"
+"customGeoErrDownload" = "Falha no download"
+"customGeoErrUpdateAllIncomplete" = "Falha ao atualizar uma ou mais fontes geo personalizadas"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "Tráfego Total"
 "allTimeTraffic" = "Tráfego Total"
@@ -271,6 +314,7 @@
 "daily" = "Diariamente"
 "daily" = "Diariamente"
 "weekly" = "Semanalmente"
 "weekly" = "Semanalmente"
 "monthly" = "Mensalmente"
 "monthly" = "Mensalmente"
+"hourly" = "A cada hora"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Obter"
 "obtain" = "Obter"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "Essas opções roteam o tráfego para um destino específico via IPv4."
 "ipv4RoutingDesc" = "Essas opções roteam o tráfego para um destino específico via IPv4."
 "warpRouting" = "Roteamento WARP"
 "warpRouting" = "Roteamento WARP"
 "warpRoutingDesc" = "Essas opções roteam o tráfego para um destino específico via WARP."
 "warpRoutingDesc" = "Essas opções roteam o tráfego para um destino específico via WARP."
+"nordRouting" = "Roteamento NordVPN"
+"nordRoutingDesc" = "Essas opções roteiam o tráfego para um destino específico via NordVPN."
 "Template" = "Modelo de Configuração Avançada do Xray"
 "Template" = "Modelo de Configuração Avançada do Xray"
 "TemplateDesc" = "O arquivo final de configuração do Xray será gerado com base neste modelo."
 "TemplateDesc" = "O arquivo final de configuração do Xray será gerado com base neste modelo."
 "FreedomStrategy" = "Estratégia do Protocolo Freedom"
 "FreedomStrategy" = "Estratégia do Protocolo Freedom"
@@ -531,6 +577,14 @@
 "testSuccess" = "Teste bem-sucedido"
 "testSuccess" = "Teste bem-sucedido"
 "testFailed" = "Teste falhou"
 "testFailed" = "Teste falhou"
 "testError" = "Falha ao testar saída"
 "testError" = "Falha ao testar saída"
+"nordvpn" = "NordVPN"
+"accessToken" = "Token de Acesso"
+"country" = "País"
+"server" = "Servidor"
+"city" = "Cidade"
+"allCities" = "Todas as Cidades"
+"privateKey" = "Chave Privada"
+"load" = "Carga"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Adicionar Balanceador"
 "addBalancer" = "Adicionar Balanceador"

+ 55 - 1
web/translation/translate.ru_RU.toml

@@ -4,6 +4,8 @@
 "confirm" = "Подтвердить"
 "confirm" = "Подтвердить"
 "cancel" = "Отмена"
 "cancel" = "Отмена"
 "close" = "Закрыть"
 "close" = "Закрыть"
+"save" = "Сохранить"
+"logout" = "Выход"
 "create" = "Создать"
 "create" = "Создать"
 "update" = "Обновить"
 "update" = "Обновить"
 "copy" = "Копировать"
 "copy" = "Копировать"
@@ -150,6 +152,47 @@
 "geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
 "geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
 "geofilesUpdateAll" = "Обновить все"
 "geofilesUpdateAll" = "Обновить все"
 "geofileUpdatePopover" = "Геофайлы успешно обновлены"
 "geofileUpdatePopover" = "Геофайлы успешно обновлены"
+"customGeoTitle" = "Пользовательские GeoSite / GeoIP"
+"customGeoAdd" = "Добавить"
+"customGeoType" = "Тип"
+"customGeoAlias" = "Псевдоним"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Включено"
+"customGeoLastUpdated" = "Обновлено"
+"customGeoExtColumn" = "Маршрутизация (ext:…)"
+"customGeoToastUpdateAll" = "Все пользовательские источники обновлены"
+"customGeoActions" = "Действия"
+"customGeoEdit" = "Изменить"
+"customGeoDelete" = "Удалить"
+"customGeoDownload" = "Обновить сейчас"
+"customGeoModalAdd" = "Добавить источник"
+"customGeoModalEdit" = "Изменить источник"
+"customGeoModalSave" = "Сохранить"
+"customGeoDeleteConfirm" = "Удалить этот пользовательский источник?"
+"customGeoRoutingHint" = "В правилах маршрутизации используйте значение как ext:файл.dat:тег (замените тег)."
+"customGeoInvalidId" = "Некорректный идентификатор"
+"customGeoAliasesError" = "Не удалось загрузить список пользовательских geo"
+"customGeoValidationAlias" = "Псевдоним: только a-z, цифры, - и _"
+"customGeoValidationUrl" = "URL должен начинаться с http:// или https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (свой)"
+"customGeoToastList" = "Список пользовательских geo"
+"customGeoToastAdd" = "Добавить пользовательский geo"
+"customGeoToastUpdate" = "Изменить пользовательский geo"
+"customGeoToastDelete" = "Пользовательский geo-файл «{{ .fileName }}» удалён"
+"customGeoToastDownload" = "Geofile «{{ .fileName }}» обновлен"
+"customGeoErrInvalidType" = "Тип должен быть geosite или geoip"
+"customGeoErrAliasRequired" = "Укажите псевдоним"
+"customGeoErrAliasPattern" = "Псевдоним содержит недопустимые символы"
+"customGeoErrAliasReserved" = "Этот псевдоним зарезервирован"
+"customGeoErrUrlRequired" = "Укажите URL"
+"customGeoErrInvalidUrl" = "Некорректный URL"
+"customGeoErrUrlScheme" = "URL должен использовать http или https"
+"customGeoErrUrlHost" = "Некорректный хост URL"
+"customGeoErrDuplicateAlias" = "Такой псевдоним уже используется для этого типа"
+"customGeoErrNotFound" = "Источник не найден"
+"customGeoErrDownload" = "Ошибка загрузки"
+"customGeoErrUpdateAllIncomplete" = "Не удалось обновить один или несколько пользовательских источников"
 "dontRefresh" = "Установка в процессе. Не обновляйте страницу"
 "dontRefresh" = "Установка в процессе. Не обновляйте страницу"
 "logs" = "Журнал"
 "logs" = "Журнал"
 "config" = "Конфигурация"
 "config" = "Конфигурация"
@@ -271,6 +314,7 @@
 "daily" = "Ежедневно"
 "daily" = "Ежедневно"
 "weekly" = "Еженедельно"
 "weekly" = "Еженедельно"
 "monthly" = "Ежемесячно"
 "monthly" = "Ежемесячно"
+"hourly" = "Ежечасно"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Получить"
 "obtain" = "Получить"
@@ -453,7 +497,9 @@
 "ipv4Routing" = "Правила IPv4"
 "ipv4Routing" = "Правила IPv4"
 "ipv4RoutingDesc" = "Эти параметры позволят клиентам маршрутизироваться к целевым доменам только через IPv4"
 "ipv4RoutingDesc" = "Эти параметры позволят клиентам маршрутизироваться к целевым доменам только через IPv4"
 "warpRouting" = "Правила WARP"
 "warpRouting" = "Правила WARP"
-"warpRoutingDesc" = " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP."
+"warpRoutingDesc" = " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP."
+"nordRouting" = "Маршрутизация NordVPN"
+"nordRoutingDesc" = "Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через NordVPN."
 "Template" = "Шаблон конфигурации Xray"
 "Template" = "Шаблон конфигурации Xray"
 "TemplateDesc" = "На основе шаблона создаётся конфигурационный файл Xray."
 "TemplateDesc" = "На основе шаблона создаётся конфигурационный файл Xray."
 "FreedomStrategy" = "Настройка стратегии протокола Freedom"
 "FreedomStrategy" = "Настройка стратегии протокола Freedom"
@@ -531,6 +577,14 @@
 "testSuccess" = "Тест успешен"
 "testSuccess" = "Тест успешен"
 "testFailed" = "Тест не пройден"
 "testFailed" = "Тест не пройден"
 "testError" = "Не удалось протестировать исходящее подключение"
 "testError" = "Не удалось протестировать исходящее подключение"
+"nordvpn" = "NordVPN"
+"accessToken" = "Токен доступа"
+"country" = "Страна"
+"server" = "Сервер"
+"city" = "Город"
+"allCities" = "Все города"
+"privateKey" = "Приватный ключ"
+"load" = "Нагрузка"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Создать балансировщик"
 "addBalancer" = "Создать балансировщик"

+ 54 - 0
web/translation/translate.tr_TR.toml

@@ -4,6 +4,8 @@
 "confirm" = "Onayla"
 "confirm" = "Onayla"
 "cancel" = "İptal"
 "cancel" = "İptal"
 "close" = "Kapat"
 "close" = "Kapat"
+"save" = "Kaydet"
+"logout" = "Çıkış Yap"
 "create" = "Oluştur"
 "create" = "Oluştur"
 "update" = "Güncelle"
 "update" = "Güncelle"
 "copy" = "Kopyala"
 "copy" = "Kopyala"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
 "readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
 "getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
 "getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
 "getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
 "getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
+"customGeoTitle" = "Özel GeoSite / GeoIP"
+"customGeoAdd" = "Ekle"
+"customGeoType" = "Tür"
+"customGeoAlias" = "Takma ad"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Etkin"
+"customGeoLastUpdated" = "Son güncelleme"
+"customGeoExtColumn" = "Yönlendirme (ext:…)"
+"customGeoToastUpdateAll" = "Tüm özel kaynaklar güncellendi"
+"customGeoActions" = "İşlemler"
+"customGeoEdit" = "Düzenle"
+"customGeoDelete" = "Sil"
+"customGeoDownload" = "Şimdi güncelle"
+"customGeoModalAdd" = "Özel geo ekle"
+"customGeoModalEdit" = "Özel geo düzenle"
+"customGeoModalSave" = "Kaydet"
+"customGeoDeleteConfirm" = "Bu özel geo kaynağını silinsin mi?"
+"customGeoRoutingHint" = "Yönlendirme kurallarında değer sütununu ext:dosya.dat:etiket olarak kullanın (etiketi değiştirin)."
+"customGeoInvalidId" = "Geçersiz kaynak kimliği"
+"customGeoAliasesError" = "Özel geo takma adları yüklenemedi"
+"customGeoValidationAlias" = "Takma ad yalnızca küçük harf, rakam, - ve _ içerebilir"
+"customGeoValidationUrl" = "URL http:// veya https:// ile başlamalıdır"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (özel)"
+"customGeoToastList" = "Özel geo listesi"
+"customGeoToastAdd" = "Özel geo ekle"
+"customGeoToastUpdate" = "Özel geo güncelle"
+"customGeoToastDelete" = "Özel geofile \"{{ .fileName }}\" silindi"
+"customGeoToastDownload" = "\"{{ .fileName }}\" geofile güncellendi"
+"customGeoErrInvalidType" = "Tür geosite veya geoip olmalıdır"
+"customGeoErrAliasRequired" = "Takma ad gerekli"
+"customGeoErrAliasPattern" = "Takma ad izin verilmeyen karakterler içeriyor"
+"customGeoErrAliasReserved" = "Bu takma ad ayrılmış"
+"customGeoErrUrlRequired" = "URL gerekli"
+"customGeoErrInvalidUrl" = "URL geçersiz"
+"customGeoErrUrlScheme" = "URL http veya https kullanmalıdır"
+"customGeoErrUrlHost" = "URL ana bilgisayarı geçersiz"
+"customGeoErrDuplicateAlias" = "Bu takma ad bu tür için zaten kullanılıyor"
+"customGeoErrNotFound" = "Özel geo kaynağı bulunamadı"
+"customGeoErrDownload" = "İndirme başarısız"
+"customGeoErrUpdateAllIncomplete" = "Bir veya daha fazla özel geo kaynağı güncellenemedi"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "Toplam Trafik"
 "allTimeTraffic" = "Toplam Trafik"
@@ -271,6 +314,7 @@
 "daily" = "Günlük"
 "daily" = "Günlük"
 "weekly" = "Haftalık"
 "weekly" = "Haftalık"
 "monthly" = "Aylık"
 "monthly" = "Aylık"
+"hourly" = "Saatlik"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Elde Et"
 "obtain" = "Elde Et"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "Bu seçenekler belirli bir varış yerine IPv4 üzerinden trafiği yönlendirir."
 "ipv4RoutingDesc" = "Bu seçenekler belirli bir varış yerine IPv4 üzerinden trafiği yönlendirir."
 "warpRouting" = "WARP Yönlendirme"
 "warpRouting" = "WARP Yönlendirme"
 "warpRoutingDesc" = "Bu seçenekler belirli bir varış yerine WARP üzerinden trafiği yönlendirir."
 "warpRoutingDesc" = "Bu seçenekler belirli bir varış yerine WARP üzerinden trafiği yönlendirir."
+"nordRouting" = "NordVPN Yönlendirme"
+"nordRoutingDesc" = "Bu seçenekler belirli bir varış yerine NordVPN üzerinden trafiği yönlendirir."
 "Template" = "Gelişmiş Xray Yapılandırma Şablonu"
 "Template" = "Gelişmiş Xray Yapılandırma Şablonu"
 "TemplateDesc" = "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır."
 "TemplateDesc" = "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır."
 "FreedomStrategy" = "Freedom Protokol Stratejisi"
 "FreedomStrategy" = "Freedom Protokol Stratejisi"
@@ -531,6 +577,14 @@
 "testSuccess" = "Test başarılı"
 "testSuccess" = "Test başarılı"
 "testFailed" = "Test başarısız"
 "testFailed" = "Test başarısız"
 "testError" = "Giden test edilemedi"
 "testError" = "Giden test edilemedi"
+"nordvpn" = "NordVPN"
+"accessToken" = "Erişim Jetonu"
+"country" = "Ülke"
+"server" = "Sunucu"
+"city" = "Şehir"
+"allCities" = "Tüm Şehirler"
+"privateKey" = "Özel Anahtar"
+"load" = "Yük"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Dengeleyici Ekle"
 "addBalancer" = "Dengeleyici Ekle"

+ 54 - 0
web/translation/translate.uk_UA.toml

@@ -4,6 +4,8 @@
 "confirm" = "Підтвердити"
 "confirm" = "Підтвердити"
 "cancel" = "Скасувати"
 "cancel" = "Скасувати"
 "close" = "Закрити"
 "close" = "Закрити"
+"save" = "Зберегти"
+"logout" = "Вийти"
 "create" = "Створити"
 "create" = "Створити"
 "update" = "Оновити"
 "update" = "Оновити"
 "copy" = "Копіювати"
 "copy" = "Копіювати"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "Виникла помилка під час читання бази даних"
 "readDatabaseError" = "Виникла помилка під час читання бази даних"
 "getDatabaseError" = "Виникла помилка під час отримання бази даних"
 "getDatabaseError" = "Виникла помилка під час отримання бази даних"
 "getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
 "getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
+"customGeoTitle" = "Користувацькі GeoSite / GeoIP"
+"customGeoAdd" = "Додати"
+"customGeoType" = "Тип"
+"customGeoAlias" = "Псевдонім"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Увімкнено"
+"customGeoLastUpdated" = "Оновлено"
+"customGeoExtColumn" = "Маршрутизація (ext:…)"
+"customGeoToastUpdateAll" = "Усі користувацькі джерела оновлено"
+"customGeoActions" = "Дії"
+"customGeoEdit" = "Змінити"
+"customGeoDelete" = "Видалити"
+"customGeoDownload" = "Оновити зараз"
+"customGeoModalAdd" = "Додати користувацький geo"
+"customGeoModalEdit" = "Змінити користувацький geo"
+"customGeoModalSave" = "Зберегти"
+"customGeoDeleteConfirm" = "Видалити це джерело geo?"
+"customGeoRoutingHint" = "У правилах маршрутизації використовуйте значення як ext:файл.dat:тег (замініть тег)."
+"customGeoInvalidId" = "Некоректний ідентифікатор ресурсу"
+"customGeoAliasesError" = "Не вдалося завантажити псевдоніми geo"
+"customGeoValidationAlias" = "Псевдонім: лише a-z, цифри, - і _"
+"customGeoValidationUrl" = "URL має починатися з http:// або https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (власний)"
+"customGeoToastList" = "Список користувацьких geo"
+"customGeoToastAdd" = "Додати користувацький geo"
+"customGeoToastUpdate" = "Оновити користувацький geo"
+"customGeoToastDelete" = "Користувацький geofile «{{ .fileName }}» видалено"
+"customGeoToastDownload" = "Geofile «{{ .fileName }}» оновлено"
+"customGeoErrInvalidType" = "Тип має бути geosite або geoip"
+"customGeoErrAliasRequired" = "Потрібен псевдонім"
+"customGeoErrAliasPattern" = "Псевдонім містить недопустимі символи"
+"customGeoErrAliasReserved" = "Цей псевдонім зарезервовано"
+"customGeoErrUrlRequired" = "Потрібен URL"
+"customGeoErrInvalidUrl" = "Некоректний URL"
+"customGeoErrUrlScheme" = "URL має використовувати http або https"
+"customGeoErrUrlHost" = "Некоректний хост URL"
+"customGeoErrDuplicateAlias" = "Цей псевдонім уже використовується для цього типу"
+"customGeoErrNotFound" = "Джерело geo не знайдено"
+"customGeoErrDownload" = "Помилка завантаження"
+"customGeoErrUpdateAllIncomplete" = "Не вдалося оновити один або кілька користувацьких джерел"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "Загальний трафік"
 "allTimeTraffic" = "Загальний трафік"
@@ -271,6 +314,7 @@
 "daily" = "Щодня"
 "daily" = "Щодня"
 "weekly" = "Щотижня"
 "weekly" = "Щотижня"
 "monthly" = "Щомісяця"
 "monthly" = "Щомісяця"
+"hourly" = "Щогодини"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Отримати"
 "obtain" = "Отримати"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "Ці параметри спрямовуватимуть трафік на основі певного призначення через IPv4."
 "ipv4RoutingDesc" = "Ці параметри спрямовуватимуть трафік на основі певного призначення через IPv4."
 "warpRouting" = "WARP Маршрутизація"
 "warpRouting" = "WARP Маршрутизація"
 "warpRoutingDesc" = "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через WARP."
 "warpRoutingDesc" = "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через WARP."
+"nordRouting" = "Маршрутизація NordVPN"
+"nordRoutingDesc" = "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через NordVPN."
 "Template" = "Шаблон розширеної конфігурації Xray"
 "Template" = "Шаблон розширеної конфігурації Xray"
 "TemplateDesc" = "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону."
 "TemplateDesc" = "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону."
 "FreedomStrategy" = "Стратегія протоколу свободи"
 "FreedomStrategy" = "Стратегія протоколу свободи"
@@ -531,6 +577,14 @@
 "testSuccess" = "Тест успішний"
 "testSuccess" = "Тест успішний"
 "testFailed" = "Тест не пройдено"
 "testFailed" = "Тест не пройдено"
 "testError" = "Не вдалося протестувати вихідне з'єднання"
 "testError" = "Не вдалося протестувати вихідне з'єднання"
+"nordvpn" = "NordVPN"
+"accessToken" = "Токен доступу"
+"country" = "Країна"
+"server" = "Сервер"
+"city" = "Місто"
+"allCities" = "Усі міста"
+"privateKey" = "Приватний ключ"
+"load" = "Навантаження"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Додати балансир"
 "addBalancer" = "Додати балансир"

+ 54 - 0
web/translation/translate.vi_VN.toml

@@ -4,6 +4,8 @@
 "confirm" = "Xác nhận"
 "confirm" = "Xác nhận"
 "cancel" = "Hủy bỏ"
 "cancel" = "Hủy bỏ"
 "close" = "Đóng"
 "close" = "Đóng"
+"save" = "Lưu"
+"logout" = "Đăng xuất"
 "create" = "Tạo"
 "create" = "Tạo"
 "update" = "Cập nhật"
 "update" = "Cập nhật"
 "copy" = "Sao chép"
 "copy" = "Sao chép"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
 "readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
 "getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
 "getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
 "getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
 "getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
+"customGeoTitle" = "GeoSite / GeoIP tùy chỉnh"
+"customGeoAdd" = "Thêm"
+"customGeoType" = "Loại"
+"customGeoAlias" = "Bí danh"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Bật"
+"customGeoLastUpdated" = "Cập nhật lần cuối"
+"customGeoExtColumn" = "Định tuyến (ext:…)"
+"customGeoToastUpdateAll" = "Đã cập nhật tất cả nguồn tùy chỉnh"
+"customGeoActions" = "Thao tác"
+"customGeoEdit" = "Sửa"
+"customGeoDelete" = "Xóa"
+"customGeoDownload" = "Cập nhật ngay"
+"customGeoModalAdd" = "Thêm geo tùy chỉnh"
+"customGeoModalEdit" = "Sửa geo tùy chỉnh"
+"customGeoModalSave" = "Lưu"
+"customGeoDeleteConfirm" = "Xóa nguồn geo tùy chỉnh này?"
+"customGeoRoutingHint" = "Trong quy tắc định tuyến dùng cột giá trị dạng ext:file.dat:tag (thay tag)."
+"customGeoInvalidId" = "ID tài nguyên không hợp lệ"
+"customGeoAliasesError" = "Không tải được bí danh geo tùy chỉnh"
+"customGeoValidationAlias" = "Bí danh chỉ gồm chữ thường, số, - và _"
+"customGeoValidationUrl" = "URL phải bắt đầu bằng http:// hoặc https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (tùy chỉnh)"
+"customGeoToastList" = "Danh sách geo tùy chỉnh"
+"customGeoToastAdd" = "Thêm geo tùy chỉnh"
+"customGeoToastUpdate" = "Cập nhật geo tùy chỉnh"
+"customGeoToastDelete" = "Đã xóa geofile tùy chỉnh “{{ .fileName }}”"
+"customGeoToastDownload" = "Đã cập nhật geofile “{{ .fileName }}”"
+"customGeoErrInvalidType" = "Loại phải là geosite hoặc geoip"
+"customGeoErrAliasRequired" = "Cần bí danh"
+"customGeoErrAliasPattern" = "Bí danh có ký tự không hợp lệ"
+"customGeoErrAliasReserved" = "Bí danh này được dành riêng"
+"customGeoErrUrlRequired" = "Cần URL"
+"customGeoErrInvalidUrl" = "URL không hợp lệ"
+"customGeoErrUrlScheme" = "URL phải dùng http hoặc https"
+"customGeoErrUrlHost" = "Máy chủ URL không hợp lệ"
+"customGeoErrDuplicateAlias" = "Bí danh này đã dùng cho loại này"
+"customGeoErrNotFound" = "Không tìm thấy nguồn geo tùy chỉnh"
+"customGeoErrDownload" = "Tải xuống thất bại"
+"customGeoErrUpdateAllIncomplete" = "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "Tổng Lưu Lượng"
 "allTimeTraffic" = "Tổng Lưu Lượng"
@@ -271,6 +314,7 @@
 "daily" = "Hàng ngày"
 "daily" = "Hàng ngày"
 "weekly" = "Hàng tuần"
 "weekly" = "Hàng tuần"
 "monthly" = "Hàng tháng"
 "monthly" = "Hàng tháng"
+"hourly" = "Hàng giờ"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "Nhận"
 "obtain" = "Nhận"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "Những tùy chọn này sẽ chỉ định kết nối đến các tên miền mục tiêu qua IPv4."
 "ipv4RoutingDesc" = "Những tùy chọn này sẽ chỉ định kết nối đến các tên miền mục tiêu qua IPv4."
 "warpRouting" = "Định tuyến WARP"
 "warpRouting" = "Định tuyến WARP"
 "warpRoutingDesc" = "Cảnh báo: Trước khi sử dụng những tùy chọn này, hãy cài đặt WARP ở chế độ proxy socks5 trên máy chủ của bạn bằng cách làm theo các bước trên GitHub của bảng điều khiển. WARP sẽ định tuyến lưu lượng đến các trang web qua máy chủ Cloudflare."
 "warpRoutingDesc" = "Cảnh báo: Trước khi sử dụng những tùy chọn này, hãy cài đặt WARP ở chế độ proxy socks5 trên máy chủ của bạn bằng cách làm theo các bước trên GitHub của bảng điều khiển. WARP sẽ định tuyến lưu lượng đến các trang web qua máy chủ Cloudflare."
+"nordRouting" = "Định tuyến NordVPN"
+"nordRoutingDesc" = "Các tùy chọn này sẽ định tuyến lưu lượng dựa trên đích cụ thể qua NordVPN."
 "Template" = "Mẫu Cấu hình Xray"
 "Template" = "Mẫu Cấu hình Xray"
 "TemplateDesc" = "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này."
 "TemplateDesc" = "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này."
 "FreedomStrategy" = "Cấu hình Chiến lược cho Giao thức Freedom"
 "FreedomStrategy" = "Cấu hình Chiến lược cho Giao thức Freedom"
@@ -531,6 +577,14 @@
 "testSuccess" = "Kiểm tra thành công"
 "testSuccess" = "Kiểm tra thành công"
 "testFailed" = "Kiểm tra thất bại"
 "testFailed" = "Kiểm tra thất bại"
 "testError" = "Không thể kiểm tra đầu ra"
 "testError" = "Không thể kiểm tra đầu ra"
+"nordvpn" = "NordVPN"
+"accessToken" = "Mã truy cập"
+"country" = "Quốc gia"
+"server" = "Máy chủ"
+"city" = "Thành phố"
+"allCities" = "Tất cả thành phố"
+"privateKey" = "Khóa riêng"
+"load" = "Tải"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "Thêm cân bằng"
 "addBalancer" = "Thêm cân bằng"

+ 54 - 0
web/translation/translate.zh_CN.toml

@@ -4,6 +4,8 @@
 "confirm" = "确定"
 "confirm" = "确定"
 "cancel" = "取消"
 "cancel" = "取消"
 "close" = "关闭"
 "close" = "关闭"
+"save" = "保存"
+"logout" = "登出"
 "create" = "创建"
 "create" = "创建"
 "update" = "更新"
 "update" = "更新"
 "copy" = "复制"
 "copy" = "复制"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "读取数据库时出错"
 "readDatabaseError" = "读取数据库时出错"
 "getDatabaseError" = "检索数据库时出错"
 "getDatabaseError" = "检索数据库时出错"
 "getConfigError" = "检索配置文件时出错"
 "getConfigError" = "检索配置文件时出错"
+"customGeoTitle" = "自定义 GeoSite / GeoIP"
+"customGeoAdd" = "添加"
+"customGeoType" = "类型"
+"customGeoAlias" = "别名"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "启用"
+"customGeoLastUpdated" = "上次更新"
+"customGeoExtColumn" = "路由 (ext:…)"
+"customGeoToastUpdateAll" = "所有自定义来源已更新"
+"customGeoActions" = "操作"
+"customGeoEdit" = "编辑"
+"customGeoDelete" = "删除"
+"customGeoDownload" = "立即更新"
+"customGeoModalAdd" = "添加自定义 geo"
+"customGeoModalEdit" = "编辑自定义 geo"
+"customGeoModalSave" = "保存"
+"customGeoDeleteConfirm" = "删除此自定义 geo 源?"
+"customGeoRoutingHint" = "在路由规则中将值列写为 ext:文件.dat:标签(替换标签)。"
+"customGeoInvalidId" = "无效的资源 ID"
+"customGeoAliasesError" = "加载自定义 geo 别名失败"
+"customGeoValidationAlias" = "别名只能包含小写字母、数字、- 和 _"
+"customGeoValidationUrl" = "URL 必须以 http:// 或 https:// 开头"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = "(自定义)"
+"customGeoToastList" = "自定义 geo 列表"
+"customGeoToastAdd" = "添加自定义 geo"
+"customGeoToastUpdate" = "更新自定义 geo"
+"customGeoToastDelete" = "自定义 geofile「{{ .fileName }}」已删除"
+"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
+"customGeoErrInvalidType" = "类型必须是 geosite 或 geoip"
+"customGeoErrAliasRequired" = "请填写别名"
+"customGeoErrAliasPattern" = "别名包含不允许的字符"
+"customGeoErrAliasReserved" = "该别名已保留"
+"customGeoErrUrlRequired" = "请填写 URL"
+"customGeoErrInvalidUrl" = "URL 无效"
+"customGeoErrUrlScheme" = "URL 必须使用 http 或 https"
+"customGeoErrUrlHost" = "URL 主机无效"
+"customGeoErrDuplicateAlias" = "此类型下已使用该别名"
+"customGeoErrNotFound" = "未找到自定义 geo 源"
+"customGeoErrDownload" = "下载失败"
+"customGeoErrUpdateAllIncomplete" = "有一个或多个自定义 geo 源更新失败"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "累计总流量"
 "allTimeTraffic" = "累计总流量"
@@ -271,6 +314,7 @@
 "daily" = "每日"
 "daily" = "每日"
 "weekly" = "每周"
 "weekly" = "每周"
 "monthly" = "每月"
 "monthly" = "每月"
+"hourly" = "每小时"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "获取"
 "obtain" = "获取"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "此选项将仅通过 IPv4 路由到目标域"
 "ipv4RoutingDesc" = "此选项将仅通过 IPv4 路由到目标域"
 "warpRouting" = "WARP 路由"
 "warpRouting" = "WARP 路由"
 "warpRoutingDesc" = "注意:在使用这些选项之前,请按照面板 GitHub 上的步骤在你的服务器上以 socks5 代理模式安装 WARP。WARP 将通过 Cloudflare 服务器将流量路由到网站。"
 "warpRoutingDesc" = "注意:在使用这些选项之前,请按照面板 GitHub 上的步骤在你的服务器上以 socks5 代理模式安装 WARP。WARP 将通过 Cloudflare 服务器将流量路由到网站。"
+"nordRouting" = "NordVPN 路由"
+"nordRoutingDesc" = "这些选项将根据特定目的地通过 NordVPN 路由流量。"
 "Template" = "高级 Xray 配置模板"
 "Template" = "高级 Xray 配置模板"
 "TemplateDesc" = "最终的 Xray 配置文件将基于此模板生成"
 "TemplateDesc" = "最终的 Xray 配置文件将基于此模板生成"
 "FreedomStrategy" = "Freedom 协议策略"
 "FreedomStrategy" = "Freedom 协议策略"
@@ -531,6 +577,14 @@
 "testSuccess" = "测试成功"
 "testSuccess" = "测试成功"
 "testFailed" = "测试失败"
 "testFailed" = "测试失败"
 "testError" = "测试出站失败"
 "testError" = "测试出站失败"
+"nordvpn" = "NordVPN"
+"accessToken" = "访问令牌"
+"country" = "国家"
+"server" = "服务器"
+"city" = "城市"
+"allCities" = "所有城市"
+"privateKey" = "私钥"
+"load" = "负载"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "添加负载均衡"
 "addBalancer" = "添加负载均衡"

+ 54 - 0
web/translation/translate.zh_TW.toml

@@ -4,6 +4,8 @@
 "confirm" = "確定"
 "confirm" = "確定"
 "cancel" = "取消"
 "cancel" = "取消"
 "close" = "關閉"
 "close" = "關閉"
+"save" = "儲存"
+"logout" = "登出"
 "create" = "建立"
 "create" = "建立"
 "update" = "更新"
 "update" = "更新"
 "copy" = "複製"
 "copy" = "複製"
@@ -164,6 +166,47 @@
 "readDatabaseError" = "讀取資料庫時發生錯誤"
 "readDatabaseError" = "讀取資料庫時發生錯誤"
 "getDatabaseError" = "檢索資料庫時發生錯誤"
 "getDatabaseError" = "檢索資料庫時發生錯誤"
 "getConfigError" = "檢索設定檔時發生錯誤"
 "getConfigError" = "檢索設定檔時發生錯誤"
+"customGeoTitle" = "自訂 GeoSite / GeoIP"
+"customGeoAdd" = "新增"
+"customGeoType" = "類型"
+"customGeoAlias" = "別名"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "啟用"
+"customGeoLastUpdated" = "上次更新"
+"customGeoExtColumn" = "路由 (ext:…)"
+"customGeoToastUpdateAll" = "所有自訂來源已更新"
+"customGeoActions" = "操作"
+"customGeoEdit" = "編輯"
+"customGeoDelete" = "刪除"
+"customGeoDownload" = "立即更新"
+"customGeoModalAdd" = "新增自訂 geo"
+"customGeoModalEdit" = "編輯自訂 geo"
+"customGeoModalSave" = "儲存"
+"customGeoDeleteConfirm" = "刪除此自訂 geo 來源?"
+"customGeoRoutingHint" = "在路由規則中將值欄寫為 ext:檔案.dat:標籤(替換標籤)。"
+"customGeoInvalidId" = "無效的資源 ID"
+"customGeoAliasesError" = "載入自訂 geo 別名失敗"
+"customGeoValidationAlias" = "別名只能包含小寫字母、數字、- 和 _"
+"customGeoValidationUrl" = "URL 必須以 http:// 或 https:// 開頭"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = "(自訂)"
+"customGeoToastList" = "自訂 geo 清單"
+"customGeoToastAdd" = "新增自訂 geo"
+"customGeoToastUpdate" = "更新自訂 geo"
+"customGeoToastDelete" = "自訂 geofile「{{ .fileName }}」已刪除"
+"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
+"customGeoErrInvalidType" = "類型必須是 geosite 或 geoip"
+"customGeoErrAliasRequired" = "請填寫別名"
+"customGeoErrAliasPattern" = "別名包含不允許的字元"
+"customGeoErrAliasReserved" = "此別名已保留"
+"customGeoErrUrlRequired" = "請填寫 URL"
+"customGeoErrInvalidUrl" = "URL 無效"
+"customGeoErrUrlScheme" = "URL 必須使用 http 或 https"
+"customGeoErrUrlHost" = "URL 主機無效"
+"customGeoErrDuplicateAlias" = "此類型已使用該別名"
+"customGeoErrNotFound" = "找不到自訂 geo 來源"
+"customGeoErrDownload" = "下載失敗"
+"customGeoErrUpdateAllIncomplete" = "有一個或多個自訂 geo 來源更新失敗"
 
 
 [pages.inbounds]
 [pages.inbounds]
 "allTimeTraffic" = "累計總流量"
 "allTimeTraffic" = "累計總流量"
@@ -271,6 +314,7 @@
 "daily" = "每日"
 "daily" = "每日"
 "weekly" = "每週"
 "weekly" = "每週"
 "monthly" = "每月"
 "monthly" = "每月"
+"hourly" = "每小時"
 
 
 [pages.inbounds.toasts]
 [pages.inbounds.toasts]
 "obtain" = "獲取"
 "obtain" = "獲取"
@@ -454,6 +498,8 @@
 "ipv4RoutingDesc" = "此選項將僅通過 IPv4 路由到目標域"
 "ipv4RoutingDesc" = "此選項將僅通過 IPv4 路由到目標域"
 "warpRouting" = "WARP 路由"
 "warpRouting" = "WARP 路由"
 "warpRoutingDesc" = "注意:在使用這些選項之前,請按照面板 GitHub 上的步驟在你的伺服器上以 socks5 代理模式安裝 WARP。WARP 將通過 Cloudflare 伺服器將流量路由到網站。"
 "warpRoutingDesc" = "注意:在使用這些選項之前,請按照面板 GitHub 上的步驟在你的伺服器上以 socks5 代理模式安裝 WARP。WARP 將通過 Cloudflare 伺服器將流量路由到網站。"
+"nordRouting" = "NordVPN 路由"
+"nordRoutingDesc" = "這些選項將根據特定目的地通過 NordVPN 路由流量。"
 "Template" = "高階 Xray 配置模板"
 "Template" = "高階 Xray 配置模板"
 "TemplateDesc" = "最終的 Xray 配置檔案將基於此模板生成"
 "TemplateDesc" = "最終的 Xray 配置檔案將基於此模板生成"
 "FreedomStrategy" = "Freedom 協議策略"
 "FreedomStrategy" = "Freedom 協議策略"
@@ -531,6 +577,14 @@
 "testSuccess" = "測試成功"
 "testSuccess" = "測試成功"
 "testFailed" = "測試失敗"
 "testFailed" = "測試失敗"
 "testError" = "測試出站失敗"
 "testError" = "測試出站失敗"
+"nordvpn" = "NordVPN"
+"accessToken" = "訪問令牌"
+"country" = "國家"
+"server" = "伺服器"
+"city" = "城市"
+"allCities" = "所有城市"
+"privateKey" = "私密金鑰"
+"load" = "負載"
 
 
 [pages.xray.balancer]
 [pages.xray.balancer]
 "addBalancer" = "新增負載均衡"
 "addBalancer" = "新增負載均衡"

+ 28 - 4
web/web.go

@@ -12,6 +12,7 @@ import (
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
+	"path/filepath"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -101,9 +102,10 @@ type Server struct {
 	api   *controller.APIController
 	api   *controller.APIController
 	ws    *controller.WebSocketController
 	ws    *controller.WebSocketController
 
 
-	xrayService    service.XrayService
-	settingService service.SettingService
-	tgbotService   service.Tgbot
+	xrayService      service.XrayService
+	settingService   service.SettingService
+	tgbotService     service.Tgbot
+	customGeoService *service.CustomGeoService
 
 
 	wsHub *websocket.Hub
 	wsHub *websocket.Hub
 
 
@@ -268,7 +270,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 
 	s.index = controller.NewIndexController(g)
 	s.index = controller.NewIndexController(g)
 	s.panel = controller.NewXUIController(g)
 	s.panel = controller.NewXUIController(g)
-	s.api = controller.NewAPIController(g)
+	s.api = controller.NewAPIController(g, s.customGeoService)
 
 
 	// Initialize WebSocket hub
 	// Initialize WebSocket hub
 	s.wsHub = websocket.NewHub()
 	s.wsHub = websocket.NewHub()
@@ -292,9 +294,27 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	return engine, nil
 	return engine, nil
 }
 }
 
 
+// normalizeExistingGeositeFiles normalizes country codes in all geosite .dat
+// files found in the bin directory so Xray-core can locate entries correctly.
+func normalizeExistingGeositeFiles() {
+	binDir := config.GetBinFolderPath()
+	matches, err := filepath.Glob(filepath.Join(binDir, "geosite*.dat"))
+	if err != nil {
+		logger.Warningf("Failed to glob geosite files: %v", err)
+		return
+	}
+	for _, path := range matches {
+		if err := service.NormalizeGeositeCountryCodes(path); err != nil {
+			logger.Warningf("Failed to normalize geosite country codes in %s: %v", path, err)
+		}
+	}
+}
+
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
 // jobs) which the panel relies on for periodic maintenance and monitoring.
 func (s *Server) startTask() {
 func (s *Server) startTask() {
+	normalizeExistingGeositeFiles()
+	s.customGeoService.EnsureOnStartup()
 	err := s.xrayService.RestartXray(true)
 	err := s.xrayService.RestartXray(true)
 	if err != nil {
 	if err != nil {
 		logger.Warning("start xray failed:", err)
 		logger.Warning("start xray failed:", err)
@@ -325,6 +345,8 @@ func (s *Server) startTask() {
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
 
 
 	// Inbound traffic reset jobs
 	// Inbound traffic reset jobs
+	// Run every hour
+	s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
 	// Run once a day, midnight
 	// Run once a day, midnight
 	s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
 	s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
 	// Run once a week, midnight between Sat/Sun
 	// Run once a week, midnight between Sat/Sun
@@ -388,6 +410,8 @@ func (s *Server) Start() (err error) {
 	s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
 	s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
 	s.cron.Start()
 	s.cron.Start()
 
 
+	s.customGeoService = service.NewCustomGeoService()
+
 	engine, err := s.initRouter()
 	engine, err := s.initRouter()
 	if err != nil {
 	if err != nil {
 		return err
 		return err

+ 59 - 37
web/websocket/hub.go

@@ -21,6 +21,7 @@ const (
 	MessageTypeNotification MessageType = "notification" // System notification
 	MessageTypeNotification MessageType = "notification" // System notification
 	MessageTypeXrayState    MessageType = "xray_state"   // Xray state change
 	MessageTypeXrayState    MessageType = "xray_state"   // Xray state change
 	MessageTypeOutbounds    MessageType = "outbounds"    // Outbounds list update
 	MessageTypeOutbounds    MessageType = "outbounds"    // Outbounds list update
+	MessageTypeInvalidate   MessageType = "invalidate"   // Lightweight signal telling frontend to re-fetch data via REST
 )
 )
 
 
 // Message represents a WebSocket message
 // Message represents a WebSocket message
@@ -32,10 +33,11 @@ type Message struct {
 
 
 // Client represents a WebSocket client connection
 // Client represents a WebSocket client connection
 type Client struct {
 type Client struct {
-	ID     string
-	Send   chan []byte
-	Hub    *Hub
-	Topics map[MessageType]bool // Subscribed topics
+	ID        string
+	Send      chan []byte
+	Hub       *Hub
+	Topics    map[MessageType]bool // Subscribed topics
+	closeOnce sync.Once            // Ensures Send channel is closed exactly once
 }
 }
 
 
 // Hub maintains the set of active clients and broadcasts messages to them
 // Hub maintains the set of active clients and broadcasts messages to them
@@ -61,7 +63,6 @@ type Hub struct {
 
 
 	// Worker pool for parallel broadcasting
 	// Worker pool for parallel broadcasting
 	workerPoolSize int
 	workerPoolSize int
-	broadcastWg    sync.WaitGroup
 }
 }
 
 
 // NewHub creates a new WebSocket hub
 // NewHub creates a new WebSocket hub
@@ -104,20 +105,12 @@ func (h *Hub) Run() {
 			// Graceful shutdown: close all clients
 			// Graceful shutdown: close all clients
 			h.mu.Lock()
 			h.mu.Lock()
 			for client := range h.clients {
 			for client := range h.clients {
-				// Safely close channel (avoid double close panic)
-				select {
-				case _, stillOpen := <-client.Send:
-					if stillOpen {
-						close(client.Send)
-					}
-				default:
+				client.closeOnce.Do(func() {
 					close(client.Send)
 					close(client.Send)
-				}
+				})
 			}
 			}
 			h.clients = make(map[*Client]bool)
 			h.clients = make(map[*Client]bool)
 			h.mu.Unlock()
 			h.mu.Unlock()
-			// Wait for all broadcast workers to finish
-			h.broadcastWg.Wait()
 			logger.Info("WebSocket hub stopped gracefully")
 			logger.Info("WebSocket hub stopped gracefully")
 			return
 			return
 
 
@@ -138,19 +131,9 @@ func (h *Hub) Run() {
 			h.mu.Lock()
 			h.mu.Lock()
 			if _, ok := h.clients[client]; ok {
 			if _, ok := h.clients[client]; ok {
 				delete(h.clients, client)
 				delete(h.clients, client)
-				// Safely close channel (avoid double close panic)
-				// Check if channel is already closed by trying to read from it
-				select {
-				case _, stillOpen := <-client.Send:
-					if stillOpen {
-						// Channel was open and had data, now it's empty, safe to close
-						close(client.Send)
-					}
-					// If stillOpen is false, channel was already closed, do nothing
-				default:
-					// Channel is empty and open, safe to close
+				client.closeOnce.Do(func() {
 					close(client.Send)
 					close(client.Send)
-				}
+				})
 			}
 			}
 			count := len(h.clients)
 			count := len(h.clients)
 			h.mu.Unlock()
 			h.mu.Unlock()
@@ -220,11 +203,12 @@ func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
 	}
 	}
 	close(clientChan)
 	close(clientChan)
 
 
-	// Start workers for parallel processing
-	h.broadcastWg.Add(h.workerPoolSize)
+	// Use a local WaitGroup to avoid blocking hub shutdown
+	var wg sync.WaitGroup
+	wg.Add(h.workerPoolSize)
 	for i := 0; i < h.workerPoolSize; i++ {
 	for i := 0; i < h.workerPoolSize; i++ {
 		go func() {
 		go func() {
-			defer h.broadcastWg.Done()
+			defer wg.Done()
 			for client := range clientChan {
 			for client := range clientChan {
 				func() {
 				func() {
 					defer func() {
 					defer func() {
@@ -246,7 +230,7 @@ func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
 	}
 	}
 
 
 	// Wait for all workers to finish
 	// Wait for all workers to finish
-	h.broadcastWg.Wait()
+	wg.Wait()
 }
 }
 
 
 // Broadcast sends a message to all connected clients
 // Broadcast sends a message to all connected clients
@@ -259,6 +243,11 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
 		return
 		return
 	}
 	}
 
 
+	// Skip all work if no clients are connected
+	if h.GetClientCount() == 0 {
+		return
+	}
+
 	msg := Message{
 	msg := Message{
 		Type:    messageType,
 		Type:    messageType,
 		Payload: payload,
 		Payload: payload,
@@ -271,10 +260,12 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
 		return
 		return
 	}
 	}
 
 
-	// Limit message size to prevent memory issues
-	const maxMessageSize = 1024 * 1024 // 1MB
+	// If message exceeds size limit, send a lightweight invalidate notification
+	// instead of dropping it entirely — the frontend will re-fetch via REST API
+	const maxMessageSize = 10 * 1024 * 1024 // 10MB
 	if len(data) > maxMessageSize {
 	if len(data) > maxMessageSize {
-		logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
+		logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
+		h.broadcastInvalidate(messageType)
 		return
 		return
 	}
 	}
 
 
@@ -298,6 +289,11 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
 		return
 		return
 	}
 	}
 
 
+	// Skip all work if no clients are connected
+	if h.GetClientCount() == 0 {
+		return
+	}
+
 	msg := Message{
 	msg := Message{
 		Type:    messageType,
 		Type:    messageType,
 		Payload: payload,
 		Payload: payload,
@@ -310,10 +306,11 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
 		return
 		return
 	}
 	}
 
 
-	// Limit message size to prevent memory issues
-	const maxMessageSize = 1024 * 1024 // 1MB
+	// If message exceeds size limit, send a lightweight invalidate notification
+	const maxMessageSize = 10 * 1024 * 1024 // 10MB
 	if len(data) > maxMessageSize {
 	if len(data) > maxMessageSize {
-		logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
+		logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
+		h.broadcastInvalidate(messageType)
 		return
 		return
 	}
 	}
 
 
@@ -374,6 +371,31 @@ func (h *Hub) Stop() {
 	}
 	}
 }
 }
 
 
+// broadcastInvalidate sends a lightweight invalidate message to all clients,
+// telling them to re-fetch the specified data type via REST API.
+// This is used when the full payload exceeds the WebSocket message size limit.
+func (h *Hub) broadcastInvalidate(originalType MessageType) {
+	msg := Message{
+		Type:    MessageTypeInvalidate,
+		Payload: map[string]string{"type": string(originalType)},
+		Time:    getCurrentTimestamp(),
+	}
+
+	data, err := json.Marshal(msg)
+	if err != nil {
+		logger.Error("Failed to marshal invalidate message:", err)
+		return
+	}
+
+	// Non-blocking send with timeout
+	select {
+	case h.broadcast <- data:
+	case <-time.After(100 * time.Millisecond):
+		logger.Warning("WebSocket broadcast channel is full, dropping invalidate message")
+	case <-h.ctx.Done():
+	}
+}
+
 // getCurrentTimestamp returns current Unix timestamp in milliseconds
 // getCurrentTimestamp returns current Unix timestamp in milliseconds
 func getCurrentTimestamp() int64 {
 func getCurrentTimestamp() int64 {
 	return time.Now().UnixMilli()
 	return time.Now().UnixMilli()

+ 21 - 0
web/websocket/notifier.go

@@ -24,6 +24,16 @@ func GetHub() *Hub {
 	return wsHub
 	return wsHub
 }
 }
 
 
+// HasClients returns true if there are any WebSocket clients connected.
+// Use this to skip expensive work (DB queries, serialization) when no browser is open.
+func HasClients() bool {
+	hub := GetHub()
+	if hub == nil {
+		return false
+	}
+	return hub.GetClientCount() > 0
+}
+
 // BroadcastStatus broadcasts server status update to all connected clients
 // BroadcastStatus broadcasts server status update to all connected clients
 func BroadcastStatus(status any) {
 func BroadcastStatus(status any) {
 	hub := GetHub()
 	hub := GetHub()
@@ -80,3 +90,14 @@ func BroadcastXrayState(state string, errorMsg string) {
 		hub.Broadcast(MessageTypeXrayState, stateUpdate)
 		hub.Broadcast(MessageTypeXrayState, stateUpdate)
 	}
 	}
 }
 }
+
+// BroadcastInvalidate sends a lightweight invalidate signal for the given data type,
+// telling connected frontends to re-fetch data via REST API.
+// Use this instead of BroadcastInbounds/BroadcastOutbounds when you know the payload
+// will be too large, to avoid wasting resources on serialization.
+func BroadcastInvalidate(dataType MessageType) {
+	hub := GetHub()
+	if hub != nil {
+		hub.broadcastInvalidate(dataType)
+	}
+}

Деякі файли не було показано, через те що забагато файлів було змінено