11 Revize 6e80a468e3 ... 380aff4d82

Autor SHA1 Zpráva Datum
  Duxxie 380aff4d82 Add remote routing URL support (#6168) před 9 hodinami
  korsun009 3a2f9b48da feat(web): add network-only PWA installability (#6190) před 9 hodinami
  n0ctal 3f1dd4bf5a fix: follow-ups from the post-merge reviews of #6221, #6227, #6230 and #6239 (#6250) před 9 hodinami
  jason zhang abd320994a Add per-client external link controls (#5650) před 11 hodinami
  shustovTE 708a69acde fix(reality): make the REALITY target check usable on a private network (#6242) před 11 hodinami
  n0ctal f75ea08ab4 fix(frontend): keep the MSW worker in step with the lockfile (#6222) před 11 hodinami
  n0ctal 5c7ca5b579 feat(clients): give each client its own traffic reset cycle (#6240) před 12 hodinami
  n0ctal b8903fadf4 feat(clients): renew on a calendar day instead of a rolling interval (#6239) před 12 hodinami
  Sanaei 1872659d83 chore(gitignore): fully ignore internal/web/dist, build stub included před 12 hodinami
  Sanaei 6638ac4a1e i18n: translate importKeepHostSettings keys před 12 hodinami
  n0ctal d6472740dc feat(limitip): let operators exempt trusted addresses from the IP limit (#6230) před 12 hodinami
100 změnil soubory, kde provedl 5213 přidání a 354 odebrání
  1. 3 0
      .github/workflows/ci.yml
  2. 0 3
      .gitignore
  3. 5 1
      Makefile
  4. 57 0
      docs/pwa-installability-verification.md
  5. 9 0
      frontend/README.md
  6. binární
      frontend/public/icons/3x-ui-16.png
  7. binární
      frontend/public/icons/3x-ui-192.png
  8. binární
      frontend/public/icons/3x-ui-24.png
  9. binární
      frontend/public/icons/3x-ui-32.png
  10. binární
      frontend/public/icons/3x-ui-512.png
  11. binární
      frontend/public/icons/3x-ui-64.png
  12. 41 0
      frontend/public/manifest.webmanifest
  13. 87 5
      frontend/public/openapi.json
  14. 14 0
      frontend/public/pwa-register.js
  15. 9 0
      frontend/public/service-worker.js
  16. 12 2
      frontend/scripts/build-openapi.mjs
  17. 12 0
      frontend/src/generated/examples.ts
  18. 59 0
      frontend/src/generated/schemas.ts
  19. 11 0
      frontend/src/generated/types.ts
  20. 11 0
      frontend/src/generated/zod.ts
  21. 10 1
      frontend/src/hooks/useClients.ts
  22. 1 0
      frontend/src/lib/remark/remarkVariables.ts
  23. 1 0
      frontend/src/models/setting.ts
  24. 8 5
      frontend/src/pages/api-docs/endpoints.ts
  25. 36 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  26. 154 31
      frontend/src/pages/clients/ClientFormModal.tsx
  27. 70 0
      frontend/src/pages/clients/ClientsPage.css
  28. 4 3
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  29. 43 22
      frontend/src/pages/inbounds/form/security/reality.tsx
  30. 44 8
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  31. 12 0
      frontend/src/pages/settings/GeneralTab.tsx
  32. 13 7
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  33. 15 0
      frontend/src/schemas/client.ts
  34. 1 0
      frontend/src/schemas/primitives/index.ts
  35. 7 0
      frontend/src/schemas/primitives/traffic-reset.ts
  36. 1 0
      frontend/src/schemas/setting.ts
  37. 59 0
      internal/database/db.go
  38. 109 73
      internal/database/model/model.go
  39. 250 2
      internal/sub/clash_service.go
  40. 10 5
      internal/sub/controller.go
  41. 38 19
      internal/sub/external_config.go
  42. 26 0
      internal/sub/external_config_test.go
  43. 40 6
      internal/sub/external_subscription.go
  44. 124 4
      internal/sub/external_subscription_test.go
  45. 11 0
      internal/sub/mutation_audit_test.go
  46. 5 0
      internal/sub/remark_vars.go
  47. 623 0
      internal/sub/remote_routing.go
  48. 750 0
      internal/sub/remote_routing_test.go
  49. 27 1
      internal/util/common/url.go
  50. 26 0
      internal/util/common/url_test.go
  51. 16 2
      internal/util/netsafe/netsafe.go
  52. 1 0
      internal/web/cadence_test.go
  53. 23 0
      internal/web/controller/dist.go
  54. 31 0
      internal/web/controller/dist_test.go
  55. 60 0
      internal/web/controller/pwa.go
  56. 95 0
      internal/web/controller/pwa_test.go
  57. 4 3
      internal/web/controller/server.go
  58. 0 0
      internal/web/dist/.gitkeep
  59. 40 1
      internal/web/entity/check_valid_test.go
  60. 46 11
      internal/web/entity/entity.go
  61. 25 2
      internal/web/job/check_client_ip_job.go
  62. 49 0
      internal/web/job/check_client_ip_job_integration_test.go
  63. 83 0
      internal/web/job/ip_limit_allowlist.go
  64. 36 0
      internal/web/job/ip_limit_allowlist_agreement_test.go
  65. 70 0
      internal/web/job/ip_limit_allowlist_test.go
  66. 206 0
      internal/web/job/periodic_traffic_reset_client_test.go
  67. 61 1
      internal/web/job/periodic_traffic_reset_job.go
  68. 31 0
      internal/web/job/remote_routing_job.go
  69. 42 0
      internal/web/service/calendar_renew.go
  70. 116 0
      internal/web/service/calendar_renew_test.go
  71. 13 1
      internal/web/service/client_bulk.go
  72. 80 0
      internal/web/service/client_crud.go
  73. 35 7
      internal/web/service/client_external_link.go
  74. 124 0
      internal/web/service/client_external_link_test.go
  75. 9 0
      internal/web/service/client_link.go
  76. 4 2
      internal/web/service/client_paging.go
  77. 12 0
      internal/web/service/client_portable.go
  78. 154 0
      internal/web/service/client_traffic_cycle_test.go
  79. 17 0
      internal/web/service/depleted_calendar_test.go
  80. 55 5
      internal/web/service/import_host_settings_test.go
  81. 391 0
      internal/web/service/inbound_autorenew_calendar_test.go
  82. 8 8
      internal/web/service/inbound_node.go
  83. 31 4
      internal/web/service/inbound_traffic.go
  84. 65 33
      internal/web/service/reality_scan.go
  85. 2 2
      internal/web/service/reality_scan_test.go
  86. 19 7
      internal/web/service/server.go
  87. 27 0
      internal/web/service/setting.go
  88. 38 0
      internal/web/service/setting_remote_routing_test.go
  89. 21 6
      internal/web/translation/ar-EG.json
  90. 19 4
      internal/web/translation/en-US.json
  91. 21 6
      internal/web/translation/es-ES.json
  92. 21 6
      internal/web/translation/fa-IR.json
  93. 21 6
      internal/web/translation/id-ID.json
  94. 21 6
      internal/web/translation/ja-JP.json
  95. 21 6
      internal/web/translation/pt-BR.json
  96. 19 4
      internal/web/translation/ru-RU.json
  97. 21 6
      internal/web/translation/tr-TR.json
  98. 19 4
      internal/web/translation/uk-UA.json
  99. 21 6
      internal/web/translation/vi-VN.json
  100. 21 6
      internal/web/translation/zh-CN.json

+ 3 - 0
.github/workflows/ci.yml

@@ -182,6 +182,9 @@ jobs:
       - name: Install
         run: npm ci
         working-directory: frontend
+      - name: Verify generated MSW worker is current
+        run: git diff --exit-code -- public/mockServiceWorker.js package-lock.json
+        working-directory: frontend
       - name: Lint
         run: npm run lint
         working-directory: frontend

+ 0 - 3
.gitignore

@@ -19,9 +19,6 @@ backup/
 bin/
 x-ui/
 dist/
-!internal/web/dist/
-internal/web/dist/*
-!internal/web/dist/.gitkeep
 release/
 node_modules/
 

+ 5 - 1
Makefile

@@ -41,6 +41,10 @@ lint: lint-go lint-fe ## All linters
 typecheck: ## tsc --noEmit
 	cd $(FRONTEND) && npm run typecheck
 
+.PHONY: msw-worker-check
+msw-worker-check: ## Verify the tracked worker matches the installed MSW runtime
+	cmp $(FRONTEND)/public/mockServiceWorker.js $(FRONTEND)/node_modules/msw/lib/mockServiceWorker.js
+
 .PHONY: test-go
 test-go: dist-stub ## Go tests (shuffle, no cache)
 	go test -shuffle=on -count=1 $(GO_PKGS)
@@ -75,5 +79,5 @@ build-storybook: ## Build the static Storybook (compile-checks all stories)
 # The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
 # both test suites, a full build, and the Storybook compile-check.
 .PHONY: verify
-verify: gen-check lint typecheck test build build-storybook ## Full local gate (mirrors CI)
+verify: gen-check lint typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI)
 	@echo "verify: OK"

+ 57 - 0
docs/pwa-installability-verification.md

@@ -0,0 +1,57 @@
+# PWA installability verification
+
+This change adds a network-only PWA surface to the login and panel pages. It
+does not cache panel data, API responses, credentials, or WebSocket traffic.
+
+## Local checks
+
+Run these commands from the repository root after installing the pinned Node
+and Go toolchains:
+
+```text
+cd frontend
+npm run typecheck
+npm run lint
+npx vitest run --project unit
+npx vitest run --project components
+npm run build
+cd ..
+go test ./...
+go build ./...
+```
+
+The built binary must serve these paths beneath the configured `webBasePath`:
+
+- `manifest.webmanifest`
+- `pwa-register.js`
+- `service-worker.js`
+- `icons/3x-ui-16.png`
+- `icons/3x-ui-24.png`
+- `icons/3x-ui-32.png`
+- `icons/3x-ui-64.png`
+- `icons/3x-ui-192.png`
+- `icons/3x-ui-512.png`
+
+The login and panel HTML must contain a manifest link and registration script
+whose URLs begin with the same runtime base path. The manifest must contain
+`display: "standalone"`, relative `start_url` and `scope`, and all six icon
+entries.
+
+## Live rollout checks
+
+Before replacing a server binary, record the current x-ui binary checksum and
+create a timestamped copy of the binary and `/etc/x-ui/x-ui.db`. Restart only
+the `x-ui` service after the candidate is staged. Because x-ui manages Xray as
+a child process, the restart can briefly interrupt VPN connections.
+
+After the restart, verify:
+
+1. `x-ui` is active and its child Xray process is running.
+2. The existing panel URL serves HTML with the PWA manifest link.
+3. The manifest, registration script, worker, and all six icons return `200`.
+4. Login, authenticated API requests, panel navigation, logout, and the panel
+   WebSocket all work.
+5. At least one VPN client can complete a fresh connection cycle.
+
+If any check fails, restore the exact binary backup, restart x-ui once, and
+repeat the checks against the original build.

+ 9 - 0
frontend/README.md

@@ -70,6 +70,15 @@ react-query into separate vendor bundles to keep the per-page
 initial JS small. The Go binary embeds this directory at compile
 time and `internal/web/controller/dist.go` serves the per-page HTML.
 
+### PWA mode
+
+The login and panel pages expose a minimal network-only Progressive Web App.
+The manifest, service worker, registration script, and icons are embedded with
+the frontend and served under the runtime `webBasePath`. The service worker
+does not use Cache Storage, does not intercept requests, and does not provide
+offline access; panel authentication, API calls, and WebSocket traffic remain
+normal network requests.
+
 ## Layout
 
 ```

binární
frontend/public/icons/3x-ui-16.png


binární
frontend/public/icons/3x-ui-192.png


binární
frontend/public/icons/3x-ui-24.png


binární
frontend/public/icons/3x-ui-32.png


binární
frontend/public/icons/3x-ui-512.png


binární
frontend/public/icons/3x-ui-64.png


+ 41 - 0
frontend/public/manifest.webmanifest

@@ -0,0 +1,41 @@
+{
+  "name": "3x-ui",
+  "short_name": "3x-ui",
+  "start_url": "./",
+  "scope": "./",
+  "display": "standalone",
+  "background_color": "#0f172a",
+  "theme_color": "#1677ff",
+  "icons": [
+    {
+      "src": "icons/3x-ui-16.png",
+      "sizes": "16x16",
+      "type": "image/png"
+    },
+    {
+      "src": "icons/3x-ui-24.png",
+      "sizes": "24x24",
+      "type": "image/png"
+    },
+    {
+      "src": "icons/3x-ui-32.png",
+      "sizes": "32x32",
+      "type": "image/png"
+    },
+    {
+      "src": "icons/3x-ui-64.png",
+      "sizes": "64x64",
+      "type": "image/png"
+    },
+    {
+      "src": "icons/3x-ui-192.png",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "icons/3x-ui-512.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ]
+}

+ 87 - 5
frontend/public/openapi.json

@@ -41,6 +41,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "ipLimitAllowlist": {
+            "type": "string"
+          },
           "ldapAutoCreate": {
             "type": "boolean"
           },
@@ -374,6 +377,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "ipLimitAllowlist",
           "ldapAutoCreate",
           "ldapAutoDelete",
           "ldapBaseDN",
@@ -512,6 +516,9 @@
           "hasWarpSecret": {
             "type": "boolean"
           },
+          "ipLimitAllowlist": {
+            "type": "string"
+          },
           "ldapAutoCreate": {
             "type": "boolean"
           },
@@ -852,6 +859,7 @@
           "hasTgBotToken",
           "hasTwoFactorToken",
           "hasWarpSecret",
+          "ipLimitAllowlist",
           "ldapAutoCreate",
           "ldapAutoDelete",
           "ldapBaseDN",
@@ -1110,6 +1118,10 @@
             "description": "Reset period in days",
             "type": "integer"
           },
+          "resetDay": {
+            "description": "Calendar renewal day 1-31, 0 = interval mode",
+            "type": "integer"
+          },
           "resetMax": {
             "description": "Max auto-renew count, 0 = unlimited",
             "type": "integer"
@@ -1145,6 +1157,22 @@
             "format": "int64",
             "type": "integer"
           },
+          "trafficReset": {
+            "description": "Per-client traffic reset cycle, independent of the inbound's own (#5497).",
+            "enum": [
+              "never",
+              "hourly",
+              "daily",
+              "weekly",
+              "monthly"
+            ],
+            "type": "string"
+          },
+          "trafficResetDay": {
+            "maximum": 31,
+            "minimum": 1,
+            "type": "integer"
+          },
           "updated_at": {
             "description": "Last update timestamp",
             "format": "int64",
@@ -1158,6 +1186,7 @@
           "expiryTime",
           "limitIp",
           "reset",
+          "resetDay",
           "resetMax",
           "security",
           "subId",
@@ -1251,6 +1280,9 @@
           "reset": {
             "type": "integer"
           },
+          "resetDay": {
+            "type": "integer"
+          },
           "resetMax": {
             "type": "integer"
           },
@@ -1272,6 +1304,12 @@
             "format": "int64",
             "type": "integer"
           },
+          "trafficReset": {
+            "type": "string"
+          },
+          "trafficResetDay": {
+            "type": "integer"
+          },
           "updatedAt": {
             "format": "int64",
             "type": "integer"
@@ -1300,6 +1338,7 @@
           "privateKey",
           "publicKey",
           "reset",
+          "resetDay",
           "resetMax",
           "reverse",
           "secret",
@@ -1307,6 +1346,8 @@
           "subId",
           "tgId",
           "totalGB",
+          "trafficReset",
+          "trafficResetDay",
           "updatedAt",
           "uuid"
         ],
@@ -1371,6 +1412,11 @@
             "example": 0,
             "type": "integer"
           },
+          "resetDay": {
+            "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+            "example": 0,
+            "type": "integer"
+          },
           "resetMax": {
             "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
             "example": 0,
@@ -1406,6 +1452,7 @@
           "lastSubFetch",
           "reset",
           "resetCount",
+          "resetDay",
           "resetMax",
           "subId",
           "total",
@@ -2920,6 +2967,11 @@
             "example": "h2",
             "type": "string"
           },
+          "certChainValid": {
+            "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
+            "example": true,
+            "type": "boolean"
+          },
           "certIssuer": {
             "example": "Google Trust Services",
             "type": "string"
@@ -2964,6 +3016,11 @@
             "example": 443,
             "type": "integer"
           },
+          "privateTarget": {
+            "description": "PrivateTarget marks a target that resolves to a loopback/private/link-local\naddress: blocked before the probe unless the caller opted in, then flagged.",
+            "example": false,
+            "type": "boolean"
+          },
           "reason": {
             "type": "string"
           },
@@ -2992,6 +3049,7 @@
         },
         "required": [
           "alpn",
+          "certChainValid",
           "certIssuer",
           "certSubject",
           "certValid",
@@ -3003,6 +3061,7 @@
           "latencyMs",
           "notAfter",
           "port",
+          "privateTarget",
           "reason",
           "serverNames",
           "target",
@@ -3349,6 +3408,7 @@
                           "lastSubFetch": 1735680000000,
                           "reset": 0,
                           "resetCount": 0,
+                          "resetDay": 0,
                           "resetMax": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
@@ -5690,7 +5750,7 @@
         "tags": [
           "Server"
         ],
-        "summary": "Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.",
+        "summary": "Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names. A target on a private/loopback address is reported with privateTarget=true and probed only when allowPrivate is set.",
         "operationId": "post_panel_api_server_scanRealityTarget",
         "requestBody": {
           "required": true,
@@ -5725,6 +5785,7 @@
                   "success": true,
                   "obj": {
                     "alpn": "h2",
+                    "certChainValid": true,
                     "certIssuer": "Google Trust Services",
                     "certSubject": "cloudflare.com",
                     "certValid": true,
@@ -5736,6 +5797,7 @@
                     "latencyMs": 180,
                     "notAfter": "2026-08-01T00:00:00Z",
                     "port": 443,
+                    "privateTarget": false,
                     "reason": "",
                     "serverNames": [
                       ""
@@ -5796,6 +5858,7 @@
                   "obj": [
                     {
                       "alpn": "h2",
+                      "certChainValid": true,
                       "certIssuer": "Google Trust Services",
                       "certSubject": "cloudflare.com",
                       "certValid": true,
@@ -5807,6 +5870,7 @@
                       "latencyMs": 180,
                       "notAfter": "2026-08-01T00:00:00Z",
                       "port": 443,
+                      "privateTarget": false,
                       "reason": "",
                       "serverNames": [
                         ""
@@ -6476,7 +6540,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
+        "summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
         "operationId": "post_panel_api_clients_email_externalLinks",
         "parameters": [
           {
@@ -6494,19 +6558,36 @@
           "content": {
             "application/json": {
               "schema": {
-                "type": "object"
+                "type": "object",
+                "properties": {
+                  "externalLinks": {
+                    "type": "array",
+                    "items": {
+                      "type": "object"
+                    },
+                    "description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET."
+                  }
+                },
+                "required": [
+                  "externalLinks"
+                ]
               },
               "example": {
                 "externalLinks": [
                   {
                     "kind": "link",
                     "value": "vless://uuid@host:443?...#srv",
-                    "remark": "DE"
+                    "remark": "DE",
+                    "enable": true,
+                    "expiryTime": 0
                   },
                   {
                     "kind": "subscription",
                     "value": "https://provider.example/sub/abc",
-                    "remark": "Provider"
+                    "remark": "Provider",
+                    "enable": false,
+                    "expiryTime": 1767225600000,
+                    "namePrefix": "[zjh] "
                   }
                 ]
               }
@@ -8154,6 +8235,7 @@
                     "lastSubFetch": 1735680000000,
                     "reset": 0,
                     "resetCount": 0,
+                    "resetDay": 0,
                     "resetMax": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,

+ 14 - 0
frontend/public/pwa-register.js

@@ -0,0 +1,14 @@
+(() => {
+  if (!('serviceWorker' in navigator)) return;
+
+  const script = document.currentScript;
+  if (!(script instanceof HTMLScriptElement)) return;
+
+  const scriptUrl = new URL(script.src, window.location.href);
+  const baseUrl = new URL('./', scriptUrl);
+  const workerUrl = new URL('service-worker.js', baseUrl);
+
+  navigator.serviceWorker.register(workerUrl.pathname, {
+    scope: baseUrl.pathname,
+  }).catch(() => {});
+})();

+ 9 - 0
frontend/public/service-worker.js

@@ -0,0 +1,9 @@
+self.addEventListener('install', (event) => {
+  event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener('activate', (event) => {
+  event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('fetch', () => {});

+ 12 - 2
frontend/scripts/build-openapi.mjs

@@ -40,6 +40,7 @@ function extractPathParams(openApiPath) {
 
 function mapType(t) {
   const v = String(t || '').toLowerCase();
+  if (v.endsWith('[]')) return 'array';
   if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
   if (v === 'float' || v === 'double') return 'number';
   if (v === 'boolean' || v === 'bool') return 'boolean';
@@ -48,6 +49,15 @@ function mapType(t) {
   return 'string';
 }
 
+function schemaFromType(t) {
+  const v = String(t || '').toLowerCase();
+  if (v.endsWith('[]')) {
+    const itemType = v.slice(0, -2);
+    return { type: 'array', items: { type: mapType(itemType) } };
+  }
+  return { type: mapType(v) };
+}
+
 function tryParseJson(raw) {
   if (typeof raw !== 'string') return undefined;
   try {
@@ -63,7 +73,7 @@ function paramToOpenApi(p) {
     in: p.in,
     required: p.in === 'path' ? true : !p.optional,
     description: p.desc || '',
-    schema: { type: mapType(p.type) },
+    schema: schemaFromType(p.type),
   };
   if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
   return out;
@@ -109,7 +119,7 @@ function buildOperation(ep, tag) {
     const required = [];
     for (const bp of bodyParams) {
       properties[bp.name] = {
-        type: mapType(bp.type),
+        ...schemaFromType(bp.type),
         description: bp.desc || '',
       };
       if (!bp.optional) required.push(bp.name);

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

@@ -5,6 +5,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "expireDiff": 0,
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
+    "ipLimitAllowlist": "",
     "ldapAutoCreate": false,
     "ldapAutoDelete": false,
     "ldapBaseDN": "",
@@ -117,6 +118,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "hasTgBotToken": false,
     "hasTwoFactorToken": false,
     "hasWarpSecret": false,
+    "ipLimitAllowlist": "",
     "ldapAutoCreate": false,
     "ldapAutoDelete": false,
     "ldapBaseDN": "",
@@ -256,6 +258,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetDay": 0,
     "resetMax": 0,
     "reverse": null,
     "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
@@ -263,6 +266,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "subId": "",
     "tgId": 0,
     "totalGB": 0,
+    "trafficReset": "never",
+    "trafficResetDay": 1,
     "updated_at": 0
   },
   "ClientInbound": {
@@ -291,6 +296,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetDay": 0,
     "resetMax": 0,
     "reverse": null,
     "secret": "",
@@ -298,6 +304,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "subId": "",
     "tgId": 0,
     "totalGB": 0,
+    "trafficReset": "",
+    "trafficResetDay": 0,
     "updatedAt": 0,
     "uuid": ""
   },
@@ -315,6 +323,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "lastSubFetch": 1735680000000,
     "reset": 0,
     "resetCount": 0,
+    "resetDay": 0,
     "resetMax": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
@@ -483,6 +492,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "lastSubFetch": 1735680000000,
         "reset": 0,
         "resetCount": 0,
+        "resetDay": 0,
         "resetMax": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,
@@ -691,6 +701,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "RealityScanResult": {
     "alpn": "h2",
+    "certChainValid": true,
     "certIssuer": "Google Trust Services",
     "certSubject": "cloudflare.com",
     "certValid": true,
@@ -702,6 +713,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "latencyMs": 180,
     "notAfter": "2026-08-01T00:00:00Z",
     "port": 443,
+    "privateTarget": false,
     "reason": "",
     "serverNames": [
       ""

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

@@ -15,6 +15,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "externalTrafficInformURI": {
         "type": "string"
       },
+      "ipLimitAllowlist": {
+        "type": "string"
+      },
       "ldapAutoCreate": {
         "type": "boolean"
       },
@@ -348,6 +351,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
+      "ipLimitAllowlist",
       "ldapAutoCreate",
       "ldapAutoDelete",
       "ldapBaseDN",
@@ -486,6 +490,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasWarpSecret": {
         "type": "boolean"
       },
+      "ipLimitAllowlist": {
+        "type": "string"
+      },
       "ldapAutoCreate": {
         "type": "boolean"
       },
@@ -826,6 +833,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasTgBotToken",
       "hasTwoFactorToken",
       "hasWarpSecret",
+      "ipLimitAllowlist",
       "ldapAutoCreate",
       "ldapAutoDelete",
       "ldapBaseDN",
@@ -1084,6 +1092,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Reset period in days",
         "type": "integer"
       },
+      "resetDay": {
+        "description": "Calendar renewal day 1-31, 0 = interval mode",
+        "type": "integer"
+      },
       "resetMax": {
         "description": "Max auto-renew count, 0 = unlimited",
         "type": "integer"
@@ -1119,6 +1131,22 @@ export const SCHEMAS: Record<string, unknown> = {
         "format": "int64",
         "type": "integer"
       },
+      "trafficReset": {
+        "description": "Per-client traffic reset cycle, independent of the inbound's own (#5497).",
+        "enum": [
+          "never",
+          "hourly",
+          "daily",
+          "weekly",
+          "monthly"
+        ],
+        "type": "string"
+      },
+      "trafficResetDay": {
+        "maximum": 31,
+        "minimum": 1,
+        "type": "integer"
+      },
       "updated_at": {
         "description": "Last update timestamp",
         "format": "int64",
@@ -1132,6 +1160,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expiryTime",
       "limitIp",
       "reset",
+      "resetDay",
       "resetMax",
       "security",
       "subId",
@@ -1225,6 +1254,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset": {
         "type": "integer"
       },
+      "resetDay": {
+        "type": "integer"
+      },
       "resetMax": {
         "type": "integer"
       },
@@ -1246,6 +1278,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "format": "int64",
         "type": "integer"
       },
+      "trafficReset": {
+        "type": "string"
+      },
+      "trafficResetDay": {
+        "type": "integer"
+      },
       "updatedAt": {
         "format": "int64",
         "type": "integer"
@@ -1274,6 +1312,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "privateKey",
       "publicKey",
       "reset",
+      "resetDay",
       "resetMax",
       "reverse",
       "secret",
@@ -1281,6 +1320,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "subId",
       "tgId",
       "totalGB",
+      "trafficReset",
+      "trafficResetDay",
       "updatedAt",
       "uuid"
     ],
@@ -1345,6 +1386,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 0,
         "type": "integer"
       },
+      "resetDay": {
+        "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+        "example": 0,
+        "type": "integer"
+      },
       "resetMax": {
         "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
         "example": 0,
@@ -1380,6 +1426,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "lastSubFetch",
       "reset",
       "resetCount",
+      "resetDay",
       "resetMax",
       "subId",
       "total",
@@ -2894,6 +2941,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "h2",
         "type": "string"
       },
+      "certChainValid": {
+        "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
+        "example": true,
+        "type": "boolean"
+      },
       "certIssuer": {
         "example": "Google Trust Services",
         "type": "string"
@@ -2938,6 +2990,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 443,
         "type": "integer"
       },
+      "privateTarget": {
+        "description": "PrivateTarget marks a target that resolves to a loopback/private/link-local\naddress: blocked before the probe unless the caller opted in, then flagged.",
+        "example": false,
+        "type": "boolean"
+      },
       "reason": {
         "type": "string"
       },
@@ -2966,6 +3023,7 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     "required": [
       "alpn",
+      "certChainValid",
       "certIssuer",
       "certSubject",
       "certValid",
@@ -2977,6 +3035,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "latencyMs",
       "notAfter",
       "port",
+      "privateTarget",
       "reason",
       "serverNames",
       "target",

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

@@ -13,6 +13,7 @@ export interface AllSetting {
   expireDiff: number;
   externalTrafficInformEnable: boolean;
   externalTrafficInformURI: string;
+  ipLimitAllowlist: string;
   ldapAutoCreate: boolean;
   ldapAutoDelete: boolean;
   ldapBaseDN: string;
@@ -126,6 +127,7 @@ export interface AllSettingView {
   hasTgBotToken: boolean;
   hasTwoFactorToken: boolean;
   hasWarpSecret: boolean;
+  ipLimitAllowlist: string;
   ldapAutoCreate: boolean;
   ldapAutoDelete: boolean;
   ldapBaseDN: string;
@@ -266,6 +268,7 @@ export interface Client {
   privateKey?: string;
   publicKey?: string;
   reset: number;
+  resetDay: number;
   resetMax: number;
   reverse?: ClientReverse | null;
   secret?: string;
@@ -273,6 +276,8 @@ export interface Client {
   subId: string;
   tgId: number;
   totalGB: number;
+  trafficReset?: string;
+  trafficResetDay?: number;
   updated_at?: number;
 }
 
@@ -303,6 +308,7 @@ export interface ClientRecord {
   privateKey: string;
   publicKey: string;
   reset: number;
+  resetDay: number;
   resetMax: number;
   reverse: unknown;
   secret: string;
@@ -310,6 +316,8 @@ export interface ClientRecord {
   subId: string;
   tgId: number;
   totalGB: number;
+  trafficReset: string;
+  trafficResetDay: number;
   updatedAt: number;
   uuid: string;
 }
@@ -329,6 +337,7 @@ export interface ClientTraffic {
   lastSubFetch: number;
   reset: number;
   resetCount: number;
+  resetDay: number;
   resetMax: number;
   subId: string;
   total: number;
@@ -662,6 +671,7 @@ export interface ProbeResultUI {
 
 export interface RealityScanResult {
   alpn: string;
+  certChainValid: boolean;
   certIssuer: string;
   certSubject: string;
   certValid: boolean;
@@ -673,6 +683,7 @@ export interface RealityScanResult {
   latencyMs: number;
   notAfter: string;
   port: number;
+  privateTarget: boolean;
   reason: string;
   serverNames: string[];
   target: string;

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

@@ -29,6 +29,7 @@ export const AllSettingSchema = z.object({
   expireDiff: z.number().int().min(0),
   externalTrafficInformEnable: z.boolean(),
   externalTrafficInformURI: z.string(),
+  ipLimitAllowlist: z.string(),
   ldapAutoCreate: z.boolean(),
   ldapAutoDelete: z.boolean(),
   ldapBaseDN: z.string(),
@@ -143,6 +144,7 @@ export const AllSettingViewSchema = z.object({
   hasTgBotToken: z.boolean(),
   hasTwoFactorToken: z.boolean(),
   hasWarpSecret: z.boolean(),
+  ipLimitAllowlist: z.string(),
   ldapAutoCreate: z.boolean(),
   ldapAutoDelete: z.boolean(),
   ldapBaseDN: z.string(),
@@ -286,6 +288,7 @@ export const ClientSchema = z.object({
   privateKey: z.string().optional(),
   publicKey: z.string().optional(),
   reset: z.number().int(),
+  resetDay: z.number().int(),
   resetMax: z.number().int(),
   reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
   secret: z.string().optional(),
@@ -293,6 +296,8 @@ export const ClientSchema = z.object({
   subId: z.string(),
   tgId: z.number().int(),
   totalGB: z.number().int(),
+  trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']).optional(),
+  trafficResetDay: z.number().int().min(1).max(31).optional(),
   updated_at: z.number().int().optional(),
 });
 export type Client = z.infer<typeof ClientSchema>;
@@ -325,6 +330,7 @@ export const ClientRecordSchema = z.object({
   privateKey: z.string(),
   publicKey: z.string(),
   reset: z.number().int(),
+  resetDay: z.number().int(),
   resetMax: z.number().int(),
   reverse: z.unknown(),
   secret: z.string(),
@@ -332,6 +338,8 @@ export const ClientRecordSchema = z.object({
   subId: z.string(),
   tgId: z.number().int(),
   totalGB: z.number().int(),
+  trafficReset: z.string(),
+  trafficResetDay: z.number().int(),
   updatedAt: z.number().int(),
   uuid: z.string(),
 });
@@ -353,6 +361,7 @@ export const ClientTrafficSchema = z.object({
   lastSubFetch: z.number().int(),
   reset: z.number().int(),
   resetCount: z.number().int(),
+  resetDay: z.number().int(),
   resetMax: z.number().int(),
   subId: z.string(),
   total: z.number().int(),
@@ -708,6 +717,7 @@ export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
 
 export const RealityScanResultSchema = z.object({
   alpn: z.string(),
+  certChainValid: z.boolean(),
   certIssuer: z.string(),
   certSubject: z.string(),
   certValid: z.boolean(),
@@ -719,6 +729,7 @@ export const RealityScanResultSchema = z.object({
   latencyMs: z.number().int(),
   notAfter: z.string(),
   port: z.number().int(),
+  privateTarget: z.boolean(),
   reason: z.string(),
   serverNames: z.array(z.string()),
   target: z.string(),

+ 10 - 1
frontend/src/hooks/useClients.ts

@@ -35,7 +35,14 @@ import { DefaultsPayloadSchema } from '@/schemas/defaults';
 import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
 
 // One row sent to POST /clients/:email/externalLinks.
-export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
+export type ExternalLinkInput = {
+  kind: 'link' | 'subscription';
+  value: string;
+  remark: string;
+  enable: boolean;
+  expiryTime: number;
+  namePrefix: string;
+};
 
 export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
 
@@ -532,6 +539,8 @@ export function useClients(options: UseClientsOptions = {}) {
       limitHwid: base.limitHwid || 0,
       tgId: Number(base.tgId) || 0,
       reset: Number(base.reset) || 0,
+      resetDay: Number(base.resetDay) || 0,
+      resetMax: Number(base.resetMax) || 0,
       group: base.group || '',
       comment: base.comment || '',
       enable: !!enable,

+ 1 - 0
frontend/src/lib/remark/remarkVariables.ts

@@ -45,6 +45,7 @@ export const REMARK_VARIABLES: RemarkVar[] = [
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
   { token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
   { token: 'RESET_DAYS', group: 'time', sample: '30' },
+  { token: 'RESET_DAY', group: 'time', sample: '15' },
   // Connection (inbound config descriptors)
   { token: 'PROTOCOL', group: 'connection', sample: 'VLESS' },
   { token: 'TRANSPORT', group: 'connection', sample: 'ws' },

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

@@ -9,6 +9,7 @@ export class AllSetting {
   webBasePath = '/';
   sessionMaxAge = 360;
   trustedProxyCIDRs = '127.0.0.1/32,::1/128';
+  ipLimitAllowlist = '';
   panelOutbound = '';
   pageSize = 25;
   expireDiff = 0;

+ 8 - 5
frontend/src/pages/api-docs/endpoints.ts

@@ -521,9 +521,12 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/server/scanRealityTarget',
-        summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.',
+        summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names. A target on a private/loopback address is reported with privateTarget=true and probed only when allowPrivate is set.',
         params: [
           { name: 'target', in: 'body (form)', type: 'string', desc: 'Candidate target as host or host:port (default port 443), e.g. www.cloudflare.com:443.' },
+          { name: 'sni', in: 'body (form)', type: 'string', optional: true, desc: 'SNI the handshake sends and the certificate is verified against (the inbound serverNames). Defaults to the target host, which a fronting proxy answers with its default certificate.' },
+          { name: 'xver', in: 'body (form)', type: 'number', optional: true, desc: 'PROXY protocol version the target expects (matches the inbound xver). 0 = none.' },
+          { name: 'allowPrivate', in: 'body (form)', type: 'boolean', optional: true, desc: 'Probe a private/internal/loopback target (LAN, Docker service name). Default false (SSRF guard blocks it and the response sets privateTarget=true).' },
         ],
         body: 'target=www.cloudflare.com:443',
         responseSchema: 'RealityScanResult',
@@ -594,7 +597,7 @@ export const sections: readonly Section[] = [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n  }\n}',
+          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [\n      { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n      { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n    ]\n  }\n}',
       },
       {
         method: 'GET',
@@ -662,12 +665,12 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/:email/externalLinks',
-        summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
+        summary: 'Replace a client\'s external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.',
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
-          { name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
+          { name: 'externalLinks', in: 'body', type: 'object[]', desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.' },
         ],
-        body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n  ]\n}',
+        body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n  ]\n}',
         response: '{\n  "success": true\n}',
       },
       {

+ 36 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -8,7 +8,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import { RandomUtil, SizeFormatter } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
-import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
+import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { useClients, type InboundOption } from '@/hooks/useClients';
@@ -37,7 +37,10 @@ const EMPTY: ClientBulkAddFormValues = {
   totalGB: 0,
   expiryTime: 0,
   reset: 0,
+  resetDay: 0,
   resetMax: 0,
+  trafficReset: 'never' as const,
+  trafficResetDay: 1,
   inboundIds: [],
 };
 
@@ -68,6 +71,7 @@ export default function ClientBulkAddModal({
   const expiryTime = useWatch({ control: methods.control, name: 'expiryTime' });
   const subId = useWatch({ control: methods.control, name: 'subId' });
   const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
+  const trafficReset = useWatch({ control: methods.control, name: 'trafficReset' });
   const [delayedStart, setDelayedStart] = useState(false);
   const [saving, setSaving] = useState(false);
   const fail2ban = useFail2banStatusQuery();
@@ -177,7 +181,10 @@ export default function ClientBulkAddModal({
           totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
+          resetDay: Number(current.resetDay) || 0,
           resetMax: Number(current.resetMax) || 0,
+          trafficReset: current.trafficReset || 'never',
+          trafficResetDay: Number(current.trafficResetDay) || 1,
           limitIp: Number(current.limitIp) || 0,
           limitHwid: Number(current.limitHwid) || 0,
           group: current.group,
@@ -377,6 +384,15 @@ export default function ClientBulkAddModal({
               <InputNumber min={0} />
             </FormField>
 
+            <FormField
+              name="resetDay"
+              label={t('pages.clients.renewOnDay')}
+              tooltip={t('pages.clients.renewOnDayDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} max={31} />
+            </FormField>
+
             <FormField
               name="resetMax"
               label={t('pages.clients.renewMax')}
@@ -385,6 +401,25 @@ export default function ClientBulkAddModal({
             >
               <InputNumber min={0} />
             </FormField>
+
+            <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
+              <Select
+                options={TRAFFIC_RESETS.map((r) => ({
+                  value: r,
+                  label: t(`pages.inbounds.periodicTrafficReset.${r}`),
+                }))}
+              />
+            </FormField>
+
+            {trafficReset === 'monthly' && (
+              <FormField
+                name="trafficResetDay"
+                label={t('pages.inbounds.periodicTrafficResetDay')}
+                transform={{ output: (v) => Number(v) || 1 }}
+              >
+                <InputNumber min={1} max={31} />
+              </FormField>
+            )}
           </Form>
         </FormProvider>
       </Modal>

+ 154 - 31
frontend/src/pages/clients/ClientFormModal.tsx

@@ -22,7 +22,7 @@ import {
 import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
-import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
+import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
 
 import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -30,11 +30,12 @@ import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
-import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
+import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
 
+
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
 
@@ -49,6 +50,11 @@ interface ExternalLinkRow {
   kind: 'link' | 'subscription';
   value: string;
   remark: string;
+  enable: boolean;
+  expiryTime: number;
+  namePrefix: string;
+  lastFetchAt: number;
+  lastFetchError: string;
 }
 
 interface ApiMsg<T = unknown> {
@@ -131,7 +137,10 @@ const EMPTY: Values = {
   delayedStart: false,
   delayedDays: 0,
   reset: 0,
+  resetDay: 0,
   resetMax: 0,
+  trafficReset: 'never' as const,
+  trafficResetDay: 1,
   limitIp: 0,
   limitHwid: 0,
   tgId: 0,
@@ -153,6 +162,11 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
     kind: l.kind === 'subscription' ? 'subscription' : 'link',
     value: l.value || '',
     remark: l.remark || '',
+    enable: l.enable !== false,
+    expiryTime: Number(l.expiryTime) || 0,
+    namePrefix: l.namePrefix || '',
+    lastFetchAt: Number(l.lastFetchAt) || 0,
+    lastFetchError: l.lastFetchError || '',
   }));
 }
 
@@ -200,6 +214,7 @@ export default function ClientFormModal({
   const secret = useWatch({ control: methods.control, name: 'secret' });
   const email = useWatch({ control: methods.control, name: 'email' });
   const uuid = useWatch({ control: methods.control, name: 'uuid' });
+  const trafficReset = useWatch({ control: methods.control, name: 'trafficReset' });
   const password = useWatch({ control: methods.control, name: 'password' });
   const subId = useWatch({ control: methods.control, name: 'subId' });
   const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
@@ -227,7 +242,16 @@ export default function ClientFormModal({
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   function addExternalLinkRow(kind: 'link' | 'subscription') {
-    appendExternalLink({ kind, value: '', remark: '' });
+    appendExternalLink({
+      kind,
+      value: '',
+      remark: '',
+      enable: true,
+      expiryTime: 0,
+      namePrefix: '',
+      lastFetchAt: 0,
+      lastFetchError: '',
+    });
   }
 
   useEffect(() => {
@@ -251,7 +275,10 @@ export default function ClientFormModal({
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
+        resetDay: Number(client.resetDay) || 0,
         resetMax: Number(client.resetMax) || 0,
+        trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
+        trafficResetDay: Number(client.trafficResetDay) || 1,
         limitIp: client.limitIp || 0,
         limitHwid: client.limitHwid || 0,
         tgId: Number(client.tgId) || 0,
@@ -540,7 +567,10 @@ email: values.email,
       delayedStart: values.delayedStart,
       delayedDays: values.delayedDays,
       reset: values.reset,
+      resetDay: values.resetDay,
       resetMax: values.resetMax,
+      trafficReset: values.trafficReset,
+      trafficResetDay: values.trafficResetDay,
       limitIp: values.limitIp,
       limitHwid: values.limitHwid,
       tgId: values.tgId,
@@ -569,7 +599,10 @@ email: values.email,
       totalGB: totalBytes,
       expiryTime,
 reset: Number(values.reset) || 0,
+      resetDay: Number(values.resetDay) || 0,
       resetMax: Number(values.resetMax) || 0,
+      trafficReset: values.trafficReset || 'never',
+      trafficResetDay: Number(values.trafficResetDay) || 1,
       limitIp: Number(values.limitIp) || 0,
       limitHwid: Number(values.limitHwid) || 0,
       tgId: Number(values.tgId) || 0,
@@ -608,7 +641,14 @@ reset: Number(values.reset) || 0,
     }
 
     const externalLinks: ExternalLinkInput[] = values.externalLinks
-      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
+      .map((r) => ({
+        kind: r.kind,
+        value: r.value.trim(),
+        remark: (r.remark || '').trim(),
+        enable: r.enable !== false,
+        expiryTime: Number(r.expiryTime) || 0,
+        namePrefix: (r.namePrefix || '').trim(),
+      }))
       .filter((r) => r.value !== '');
 
     setSubmitting(true);
@@ -789,6 +829,16 @@ reset: Number(values.reset) || 0,
                             <InputNumber min={0} style={{ width: '100%' }} />
                           </FormField>
                         </Col>
+                        <Col xs={12} md={6}>
+                          <FormField
+                            name="resetDay"
+                            label={t('pages.clients.renewOnDay')}
+                            tooltip={t('pages.clients.renewOnDayDesc')}
+                            transform={{ output: (v) => Number(v) || 0 }}
+                          >
+                            <InputNumber min={0} max={31} style={{ width: '100%' }} />
+                          </FormField>
+                        </Col>
                         <Col xs={12} md={6}>
                           <FormField
                             name="resetMax"
@@ -799,6 +849,30 @@ reset: Number(values.reset) || 0,
                             <InputNumber min={0} style={{ width: '100%' }} />
                           </FormField>
                         </Col>
+                        <Col xs={12} md={6}>
+                          <FormField
+                            name="trafficReset"
+                            label={t('pages.inbounds.periodicTrafficResetTitle')}
+                          >
+                            <Select
+                              options={TRAFFIC_RESETS.map((r) => ({
+                                value: r,
+                                label: t(`pages.inbounds.periodicTrafficReset.${r}`),
+                              }))}
+                            />
+                          </FormField>
+                        </Col>
+                        {trafficReset === 'monthly' && (
+                          <Col xs={12} md={6}>
+                            <FormField
+                              name="trafficResetDay"
+                              label={t('pages.inbounds.periodicTrafficResetDay')}
+                              transform={{ output: (v) => Number(v) || 1 }}
+                            >
+                              <InputNumber min={1} max={31} style={{ width: '100%' }} />
+                            </FormField>
+                          </Col>
+                        )}
                       </Row>
 
                       <Row gutter={16}>
@@ -995,24 +1069,40 @@ reset: Number(values.reset) || 0,
                         {linkRows.length === 0 ? (
                           <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
                         ) : linkRows.map(({ field, index }) => (
-                          <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
-                            <FormField name={`externalLinks.${index}.value`} noStyle>
-                              <Input
-                                style={{ flex: 1 }}
-                                aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
-                                placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
-                              />
-                            </FormField>
-                            <FormField name={`externalLinks.${index}.remark`} noStyle>
-                              <Input
-                                style={{ width: 140 }}
-                                aria-label={t('remark')}
-                                placeholder={t('remark')}
+                          <div key={field.id} className="external-link-card">
+                            <div className="external-link-row">
+                              <div className="external-link-enable">
+                                <FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
+                                  <Switch size="small" />
+                                </FormField>
+                                <span>{t('enable')}</span>
+                              </div>
+                              <FormField name={`externalLinks.${index}.value`} noStyle>
+                                <Input
+                                  aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                                  placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                                />
+                              </FormField>
+                              <Tooltip title={t('delete')}>
+                                <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
+                              </Tooltip>
+                            </div>
+                            <div className="external-link-details two-cols">
+                              <FormField name={`externalLinks.${index}.remark`} noStyle>
+                                <Input aria-label={t('remark')} placeholder={t('remark')} />
+                              </FormField>
+                              <Controller
+                                control={methods.control}
+                                name={`externalLinks.${index}.expiryTime`}
+                                render={({ field: expiryField }) => (
+                                  <DateTimePicker
+                                    value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
+                                    onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
+                                    placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
+                                  />
+                                )}
                               />
-                            </FormField>
-                            <Tooltip title={t('delete')}>
-                              <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
-                            </Tooltip>
+                            </div>
                           </div>
                         ))}
                       </div>
@@ -1024,17 +1114,50 @@ reset: Number(values.reset) || 0,
                         {subscriptionRows.length === 0 ? (
                           <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
                         ) : subscriptionRows.map(({ field, index }) => (
-                          <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
-                            <FormField name={`externalLinks.${index}.value`} noStyle>
-                              <Input
-                                style={{ flex: 1 }}
-                                aria-label="https://provider.example/sub/…"
-                                placeholder="https://provider.example/sub/…"
+                          <div key={field.id} className="external-link-card">
+                            <div className="external-link-row">
+                              <div className="external-link-enable">
+                                <FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
+                                  <Switch size="small" />
+                                </FormField>
+                                <span>{t('enable')}</span>
+                              </div>
+                              <FormField name={`externalLinks.${index}.value`} noStyle>
+                                <Input
+                                  aria-label="https://provider.example/sub/…"
+                                  placeholder="https://provider.example/sub/…"
+                                />
+                              </FormField>
+                              <Tooltip title={t('delete')}>
+                                <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
+                              </Tooltip>
+                            </div>
+                            <div className="external-link-details three-cols">
+                              <FormField name={`externalLinks.${index}.remark`} noStyle>
+                                <Input aria-label={t('remark')} placeholder={t('remark')} />
+                              </FormField>
+                              <FormField name={`externalLinks.${index}.namePrefix`} noStyle>
+                                <Input aria-label={t('pages.clients.namePrefix')} placeholder={t('pages.clients.namePrefix')} />
+                              </FormField>
+                              <Controller
+                                control={methods.control}
+                                name={`externalLinks.${index}.expiryTime`}
+                                render={({ field: expiryField }) => (
+                                  <DateTimePicker
+                                    value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
+                                    onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
+                                    placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
+                                  />
+                                )}
                               />
-                            </FormField>
-                            <Tooltip title={t('delete')}>
-                              <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
-                            </Tooltip>
+                            </div>
+                            <Typography.Text type={field.lastFetchError ? 'danger' : 'secondary'} className="external-link-fetch-status">
+                              {field.lastFetchError
+                                ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
+                                : field.lastFetchAt > 0
+                                  ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
+                                  : t('pages.clients.neverFetched')}
+                            </Typography.Text>
                           </div>
                         ))}
                       </div>

+ 70 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -83,6 +83,76 @@
   line-height: 18px;
 }
 
+.external-link-card {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-bottom: 12px;
+  padding: 10px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 6px;
+  background: var(--ant-color-fill-quaternary);
+}
+
+.external-link-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.external-link-row .ant-input {
+  flex: 1;
+  min-width: 0;
+}
+
+.external-link-enable {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 78px;
+  color: var(--ant-color-text-secondary);
+  white-space: nowrap;
+}
+
+.external-link-details {
+  display: grid;
+  gap: 10px;
+}
+
+.external-link-details.two-cols {
+  grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr);
+}
+
+.external-link-details.three-cols {
+  grid-template-columns: minmax(0, 1fr) minmax(160px, 0.8fr) minmax(220px, 0.8fr);
+}
+
+.external-link-fetch-status {
+  font-size: 12px;
+  line-height: 1.4;
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 640px) {
+  .external-link-row {
+    align-items: stretch;
+    flex-wrap: wrap;
+  }
+
+  .external-link-enable {
+    width: 100%;
+  }
+
+  .external-link-row .ant-input {
+    flex-basis: calc(100% - 44px);
+  }
+
+  .external-link-details.two-cols,
+  .external-link-details.three-cols {
+    grid-template-columns: 1fr;
+  }
+}
+
 .card-toolbar {
   display: flex;
   align-items: center;

+ 4 - 3
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -39,7 +39,7 @@ import {
   type InboundFormValues,
 } from '@/schemas/forms/inbound-form';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
-import { Protocols } from '@/schemas/primitives';
+import { Protocols, TRAFFIC_RESETS } from '@/schemas/primitives';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
 import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
@@ -99,7 +99,6 @@ const labelWithHint = (label: string, hint: string) => (
 );
 
 const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
-const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
 const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
 const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
 
@@ -224,6 +223,7 @@ export default function InboundFormModal({
 }: InboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
+  const [modal, modalContextHolder] = Modal.useModal();
   const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
   const setV = methods.setValue as unknown as (name: string, value: unknown) => void;
   const getV = methods.getValues as unknown as (name?: string) => unknown;
@@ -318,7 +318,7 @@ export default function InboundFormModal({
     setCertFromPanel,
     clearCertFiles,
     onSecurityChange,
-  } = useSecurityActions({ methods, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
+  } = useSecurityActions({ methods, setSaving, messageApi, modal, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
 
 
   const toggleSockopt = (on: boolean) => {
@@ -990,6 +990,7 @@ export default function InboundFormModal({
   return (
     <>
       {messageContextHolder}
+      {modalContextHolder}
       <Modal
         open={open}
         title={title}

+ 43 - 22
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -3,6 +3,7 @@ import { useFormContext } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
+import dayjs from 'dayjs';
 
 import { FormField } from '@/components/form/rhf';
 import { UTLS_FINGERPRINT } from '@/schemas/primitives';
@@ -18,9 +19,9 @@ interface RealityFormProps {
   saving: boolean;
   scanning: boolean;
   scanResult: RealityScanResult | null;
-  scanRealityTarget: () => void;
+  scanRealityTarget: (allowPrivate?: boolean) => void;
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
-  applyRealityScanResult: (result: RealityScanResult) => void;
+  applyRealityScanResult: (result: RealityScanResult, replaceServerNames?: boolean) => void;
   randomizeShortIds: () => void;
   randomizeSpiderX: () => void;
   genRealityKeypair: () => void;
@@ -46,6 +47,17 @@ export default function RealityForm({
   const { t } = useTranslation();
   const { getFieldState, trigger } = useFormContext();
   const [scannerOpen, setScannerOpen] = useState(false);
+  /*
+   * An untrusted certificate (self-signed fronting service on the LAN) is still
+   * worth reading, so subject/issuer stay visible and only the verdict is added.
+   */
+  const certSummary = (r: RealityScanResult) => {
+    const who = r.certSubject && r.certIssuer
+      ? `${r.certSubject} (${r.certIssuer})`
+      : r.certSubject || r.certIssuer;
+    if (!who) return '—';
+    return r.certValid ? who : `${who} — ${t('pages.inbounds.form.scanCertInvalid')}`;
+  };
   const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
   const revalidateMaxClientVer = () => {
     if (getFieldState(maxClientVerPath).error) {
@@ -89,7 +101,7 @@ export default function RealityForm({
           >
             <Input style={{ flex: 1 }} placeholder="example.com:443" />
           </FormField>
-          <Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
+          <Button icon={<RadarChartOutlined />} loading={scanning} onClick={() => scanRealityTarget()}>
             {t('pages.inbounds.form.scan')}
           </Button>
           <Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
@@ -100,30 +112,39 @@ export default function RealityForm({
       {scanResult && (
         <Form.Item label=" " colon={false}>
           <Alert
-            type={scanResult.feasible ? 'success' : 'warning'}
+            type={scanResult.feasible && !scanResult.privateTarget ? 'success' : 'warning'}
             showIcon
             title={
               scanResult.feasible
                 ? t('pages.inbounds.form.scanFeasible')
                 : scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
             }
-            description={
-              <Descriptions size="small" column={1}>
-                <Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
-                <Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
-                <Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
-                  {scanResult.curveID || '—'}
-                </Descriptions.Item>
-                <Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
-                  {scanResult.certValid
-                    ? `${scanResult.certSubject} (${scanResult.certIssuer})`
-                    : t('pages.inbounds.form.scanCertInvalid')}
-                </Descriptions.Item>
-                <Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
-                  {scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
-                </Descriptions.Item>
-              </Descriptions>
-            }
+            description={(
+              <>
+                {scanResult.privateTarget && (
+                  <div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
+                )}
+                <Descriptions size="small" column={1}>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanSniUsed')}>
+                    {scanResult.host || '—'}
+                  </Descriptions.Item>
+                  <Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
+                  <Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
+                    {scanResult.curveID || '—'}
+                  </Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
+                    {certSummary(scanResult)}
+                  </Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanCertExpiry')}>
+                    {scanResult.notAfter ? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm') : '—'}
+                  </Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
+                    {scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
+                  </Descriptions.Item>
+                </Descriptions>
+              </>
+            )}
           />
         </Form.Item>
       )}
@@ -282,7 +303,7 @@ export default function RealityForm({
         open={scannerOpen}
         onClose={() => setScannerOpen(false)}
         scanRealityCandidates={scanRealityCandidates}
-        onPick={applyRealityScanResult}
+        onPick={(r) => applyRealityScanResult(r, true)}
       />
     </>
   );

+ 44 - 8
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
 import { useTranslation } from 'react-i18next';
 import type { UseFormReturn } from 'react-hook-form';
 import type { MessageInstance } from 'antd/es/message/interface';
+import type { HookAPI as ModalHookAPI } from 'antd/es/modal/useModal';
 
 import { HttpUtil, RandomUtil } from '@/utils';
 import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
@@ -13,6 +14,7 @@ interface UseSecurityActionsArgs {
   methods: UseFormReturn<InboundFormValues>;
   setSaving: Dispatch<SetStateAction<boolean>>;
   messageApi: MessageInstance;
+  modal: ModalHookAPI;
   /*
    * Node the inbound is deployed to (null = central panel). "Set Cert from
    * Panel" must read the node's own cert paths for a node-assigned inbound —
@@ -29,7 +31,7 @@ interface UseSecurityActionsArgs {
  * writes the result back into the form. Lifted out of InboundFormModal so
  * the modal body stays focused on orchestration.
  */
-export function useSecurityActions({ methods, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
+export function useSecurityActions({ methods, setSaving, messageApi, modal, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
   const { t } = useTranslation();
   const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
   const getValues = methods.getValues as unknown as (name?: string) => unknown;
@@ -72,26 +74,44 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
     setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
   };
 
-  const applyRealityScanResult = (r: RealityScanResult) => {
+  /*
+   * replaceServerNames is for picking a target wholesale: keeping the previous
+   * target's SNI would leave a REALITY config that cannot work.
+   */
+  const applyRealityScanResult = (r: RealityScanResult, replaceServerNames = false) => {
     setScanResult(r);
     setValue('streamSettings.realitySettings.target', r.target);
-    if (r.serverNames?.length) {
+    /*
+     * Names off an untrusted chain are not usable as SNI; names off a trusted
+     * one are, even when the SNI sent did not match them, which is how a stale
+     * SNI recovers instead of failing every rescan.
+     */
+    if (replaceServerNames) {
+      setValue('streamSettings.realitySettings.serverNames', r.serverNames ?? []);
+    } else if ((r.certValid || r.certChainValid) && r.serverNames?.length) {
       setValue('streamSettings.realitySettings.serverNames', r.serverNames);
     }
   };
 
-  const scanRealityTarget = async () => {
+  const scanRealityTarget = async (allowPrivate = false) => {
     const target = ((getValues('streamSettings.realitySettings.target') as string | undefined) ?? '').trim();
     if (!target) {
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       return;
     }
     const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
+    /*
+     * Clients dial the target but send an SNI from serverNames, so the probe
+     * must too — a fronting proxy answers a bare target name with its default
+     * certificate, which then reads as an untrusted target.
+     */
+    const serverNames = (getValues('streamSettings.realitySettings.serverNames') as string[] | undefined) ?? [];
+    const sni = (serverNames.find((n) => typeof n === 'string' && n.trim() !== '') ?? '').trim();
     setScanning(true);
     try {
       const msg = await HttpUtil.post<RealityScanResult>(
         '/panel/api/server/scanRealityTarget',
-        { target, xver },
+        { target, sni, xver, allowPrivate },
         { silent: true },
       );
       if (!msg?.success || !msg.obj) {
@@ -101,10 +121,26 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
       }
       const r = msg.obj;
       applyRealityScanResult(r);
-      if (r.feasible) {
-        messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
-      } else {
+      /*
+       * The SSRF guard refuses a LAN/Docker target until the operator confirms
+       * it; the retry carries the opt-in for this one probe.
+       */
+      if (r.privateTarget && !allowPrivate) {
+        modal.confirm({
+          title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
+          content: t('pages.inbounds.form.scanPrivateConfirmContent', { target: r.target || target }),
+          okText: t('confirm'),
+          cancelText: t('cancel'),
+          onOk: () => scanRealityTarget(true),
+        });
+        return;
+      }
+      if (!r.feasible) {
         messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
+      } else if (r.privateTarget) {
+        messageApi.warning(t('pages.inbounds.toasts.scanRealityTargetPrivate'));
+      } else {
+        messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
       }
     } finally {
       setScanning(false);

+ 12 - 0
frontend/src/pages/settings/GeneralTab.tsx

@@ -191,6 +191,18 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               />
             </SettingListItem>
 
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.ipLimitAllowlist')}
+              description={t('pages.settings.ipLimitAllowlistDesc')}
+            >
+              <Input
+                value={allSetting.ipLimitAllowlist}
+                placeholder="203.0.113.10,198.51.100.0/24"
+                onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
+              />
+            </SettingListItem>
+
             <SettingListItem paddings="small" title={t('pages.settings.panelOutbound')} description={t('pages.settings.panelOutboundDesc')}>
               <Select
                 style={{ width: '100%' }}

+ 13 - 7
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,4 +1,4 @@
-import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
+import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
 import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router';
@@ -15,6 +15,12 @@ interface SubscriptionGeneralTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
+const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
+
+const remoteSourceBadge = (value: string) => (
+  isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined
+);
+
 export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
   const navigate = useNavigate();
@@ -193,8 +199,8 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
               <Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} description={t('pages.settings.subRoutingRulesDesc')}>
-              <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
+            <SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} badge={remoteSourceBadge(allSetting.subRoutingRules)} description={t('pages.settings.subRoutingRulesDesc')}>
+              <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
                 onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subHideSettings')} description={t('pages.settings.subHideSettingsDesc')}>
@@ -211,11 +217,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
               <Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} badge={remoteSourceBadge(allSetting.subClashRules)} description={t('pages.settings.subClashRoutingRulesDesc')}>
               <Input.TextArea
                 value={allSetting.subClashRules}
                 rows={8}
-                placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
+                placeholder={'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'}
                 onChange={(e) => updateSetting({ subClashRules: e.target.value })}
               />
             </SettingListItem>
@@ -230,8 +236,8 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subIncyEnableRouting')} description={t('pages.settings.subIncyEnableRoutingDesc')}>
               <Switch checked={allSetting.subIncyEnableRouting} onChange={(v) => updateSetting({ subIncyEnableRouting: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} description={t('pages.settings.subIncyRoutingRulesDesc')}>
-              <Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/..."
+            <SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} badge={remoteSourceBadge(allSetting.subIncyRoutingRules)} description={t('pages.settings.subIncyRoutingRulesDesc')}>
+              <Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
                 onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })} />
             </SettingListItem>
           </>

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

@@ -33,7 +33,10 @@ export const ClientRecordSchema = z.object({
   comment: z.string().optional(),
   enable: z.boolean().optional(),
   reset: z.number().optional(),
+  resetDay: z.number().optional(),
   resetMax: z.number().optional(),
+  trafficReset: z.string().optional(),
+  trafficResetDay: z.number().optional(),
   inboundIds: nullableNumberArray.optional(),
   traffic: ClientTrafficSchema.nullable().optional(),
   reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
@@ -103,9 +106,15 @@ export const ClientPageResponseSchema = z.object({
 // A per-client external link surfaced in the client's subscription:
 // kind=link is a single share link, kind=subscription is a remote sub URL.
 export const ExternalLinkSchema = z.object({
+  id: z.number().int().optional().default(0),
   kind: z.enum(['link', 'subscription']).default('link'),
   value: z.string(),
   remark: z.string().optional().default(''),
+  enable: z.preprocess((v) => (v == null ? true : v), z.boolean()).default(true),
+  expiryTime: z.number().int().optional().default(0),
+  namePrefix: z.string().optional().default(''),
+  lastFetchAt: z.number().int().optional().default(0),
+  lastFetchError: z.string().optional().default(''),
 }).loose();
 
 export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
@@ -208,7 +217,10 @@ export const ClientFormSchema = z.object({
   delayedStart: z.boolean(),
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
+  resetDay: z.number().int().min(0).max(31),
   resetMax: z.number().int().min(0),
+  trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
+  trafficResetDay: z.number().int().min(1).max(31),
   limitIp: z.number().int().min(0),
   limitHwid: z.number().int().min(0),
   tgId: z.number().int().min(0),
@@ -248,7 +260,10 @@ export const ClientBulkAddFormSchema = z.object({
   totalGB: z.number().min(0),
   expiryTime: z.number(),
   reset: z.number().int().min(0),
+  resetDay: z.number().int().min(0).max(31),
   resetMax: z.number().int().min(0),
+  trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']).optional(),
+  trafficResetDay: z.number().int().min(1).max(31).optional(),
   inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
 });
 

+ 1 - 0
frontend/src/schemas/primitives/index.ts

@@ -4,3 +4,4 @@ export * from './outbound-protocol';
 export * from './sniffing';
 export * from './flow';
 export * from './options';
+export * from './traffic-reset';

+ 7 - 0
frontend/src/schemas/primitives/traffic-reset.ts

@@ -0,0 +1,7 @@
+/**
+ * The traffic reset cycles an inbound or a client may be put on. Shared so the
+ * inbound form, the client form and the bulk-add form cannot drift apart.
+ */
+export const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
+
+export type TrafficResetCycle = (typeof TRAFFIC_RESETS)[number];

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

@@ -13,6 +13,7 @@ export const AllSettingSchema = z.object({
   webBasePath: absolutePath.optional(),
   sessionMaxAge: z.number().int().min(1).max(525600).optional(),
   trustedProxyCIDRs: z.string().optional(),
+  ipLimitAllowlist: z.string().optional(),
   panelOutbound: z.string().optional(),
   pageSize: z.number().int().min(0).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),

+ 59 - 0
internal/database/db.go

@@ -137,6 +137,12 @@ func initModels() error {
 	if err := normalizeInboundSubSortIndex(); err != nil {
 		return err
 	}
+	if err := normalizeClientExternalLinkEnable(); err != nil {
+		return err
+	}
+	if err := normalizeClientExternalLinkTimestamps(); err != nil {
+		return err
+	}
 	if err := repairOverflowedTrafficCounters(); err != nil {
 		return err
 	}
@@ -155,6 +161,9 @@ func initModels() error {
 	if err := migrateTgIDIndex(); err != nil {
 		return err
 	}
+	if err := migrateClientTrafficResetColumns(); err != nil {
+		return err
+	}
 	if err := migrateSyncOrphanColumns(); err != nil {
 		return err
 	}
@@ -315,6 +324,22 @@ func rebuildInboundsWithoutInlineUniquePort() error {
 	})
 }
 
+// AutoMigrate adds the columns; an older SQLite ALTER TABLE leaves them NULL,
+// and a NULL traffic_reset fails every ClientRecord scan, not just the new query.
+func migrateClientTrafficResetColumns() error {
+	if db.Migrator().HasColumn(&model.ClientRecord{}, "traffic_reset") {
+		if err := db.Exec("UPDATE clients SET traffic_reset = 'never' WHERE traffic_reset IS NULL").Error; err != nil {
+			return err
+		}
+	}
+	if db.Migrator().HasColumn(&model.ClientRecord{}, "traffic_reset_day") {
+		if err := db.Exec("UPDATE clients SET traffic_reset_day = 1 WHERE traffic_reset_day IS NULL").Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // AutoMigrate adds the column; this only backfills the NULLs an older SQLite
 // ALTER TABLE leaves behind, so the reaper's predicate never compares to NULL.
 func migrateSyncOrphanColumns() error {
@@ -946,6 +971,40 @@ func normalizeInboundSubSortIndex() error {
 	return nil
 }
 
+// normalizeClientExternalLinkEnable keeps external-link rows written before the
+// enable column existed enabled; disabled rows from newer builds stay false.
+func normalizeClientExternalLinkEnable() error {
+	res := db.Exec("UPDATE client_external_links SET enable = ? WHERE enable IS NULL", true)
+	if res.Error != nil {
+		log.Printf("Error normalizing client external link enable: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Normalized enable on %d client external link(s)", res.RowsAffected)
+	}
+	return nil
+}
+
+// normalizeClientExternalLinkTimestamps zeroes the NULLs an older build could
+// leave behind, so the sub-side expiry predicate never drops a legacy row.
+func normalizeClientExternalLinkTimestamps() error {
+	res := db.Exec("UPDATE client_external_links SET expiry_time = 0 WHERE expiry_time IS NULL")
+	if res.Error != nil {
+		log.Printf("Error normalizing client external link expiry_time: %v", res.Error)
+		return res.Error
+	}
+	expiryRows := res.RowsAffected
+	res = db.Exec("UPDATE client_external_links SET last_fetch_at = 0 WHERE last_fetch_at IS NULL")
+	if res.Error != nil {
+		log.Printf("Error normalizing client external link last_fetch_at: %v", res.Error)
+		return res.Error
+	}
+	if expiryRows+res.RowsAffected > 0 {
+		log.Printf("Normalized timestamps on %d client external link(s)", expiryRows+res.RowsAffected)
+	}
+	return nil
+}
+
 // repairOverflowedTrafficCounters heals traffic counters that historic
 // compounding bugs pushed past int64: on SQLite an overflowing INTEGER is
 // silently promoted to REAL, after which the column no longer scans into the

+ 109 - 73
internal/database/model/model.go

@@ -894,40 +894,47 @@ type Client struct {
 	Group        string         `json:"group,omitempty" form:"group"` // Logical grouping label
 	Comment      string         `json:"comment" form:"comment"`       // Client comment
 	Reset        int            `json:"reset" form:"reset"`           // Reset period in days
+	ResetDay     int            `json:"resetDay" form:"resetDay"`     // Calendar renewal day 1-31, 0 = interval mode
 	ResetMax     int            `json:"resetMax" form:"resetMax"`     // Max auto-renew count, 0 = unlimited
-	CreatedAt    int64          `json:"created_at,omitempty"`         // Creation timestamp
-	UpdatedAt    int64          `json:"updated_at,omitempty"`         // Last update timestamp
+	// Per-client traffic reset cycle, independent of the inbound's own (#5497).
+	TrafficReset    string `json:"trafficReset,omitempty" form:"trafficReset" validate:"omitempty,oneof=never hourly daily weekly monthly"`
+	TrafficResetDay int    `json:"trafficResetDay,omitempty" form:"trafficResetDay" validate:"omitempty,gte=1,lte=31"`
+	CreatedAt       int64  `json:"created_at,omitempty"` // Creation timestamp
+	UpdatedAt       int64  `json:"updated_at,omitempty"` // Last update timestamp
 }
 
 type ClientRecord struct {
-	Id           int    `json:"id" gorm:"primaryKey;autoIncrement"`
-	Email        string `json:"email" gorm:"uniqueIndex;not null"`
-	SubID        string `json:"subId" gorm:"index;column:sub_id"`
-	UUID         string `json:"uuid" gorm:"column:uuid"`
-	Password     string `json:"password"`
-	Auth         string `json:"auth"`
-	Flow         string `json:"flow"`
-	Security     string `json:"security"`
-	Reverse      string `json:"reverse" gorm:"column:reverse"`
-	PrivateKey   string `json:"privateKey" gorm:"column:wg_private_key"`
-	PublicKey    string `json:"publicKey" gorm:"column:wg_public_key"`
-	AllowedIPs   string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
-	PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
-	KeepAlive    int    `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
-	Secret       string `json:"secret" gorm:"column:secret"`
-	AdTag        string `json:"adTag" gorm:"column:ad_tag;default:''"`
-	LimitIP      int    `json:"limitIp" gorm:"column:limit_ip"`
-	LimitHwid    int    `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
-	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
-	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
-	Enable       bool   `json:"enable" gorm:"default:true"`
-	TgID         int64  `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
-	Group        string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
-	Comment      string `json:"comment"`
-	Reset        int    `json:"reset" gorm:"default:0"`
-	ResetMax     int    `json:"resetMax" gorm:"column:reset_max;default:0"`
-	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
-	UpdatedAt    int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+	Id              int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Email           string `json:"email" gorm:"uniqueIndex;not null"`
+	SubID           string `json:"subId" gorm:"index;column:sub_id"`
+	UUID            string `json:"uuid" gorm:"column:uuid"`
+	Password        string `json:"password"`
+	Auth            string `json:"auth"`
+	Flow            string `json:"flow"`
+	Security        string `json:"security"`
+	Reverse         string `json:"reverse" gorm:"column:reverse"`
+	PrivateKey      string `json:"privateKey" gorm:"column:wg_private_key"`
+	PublicKey       string `json:"publicKey" gorm:"column:wg_public_key"`
+	AllowedIPs      string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
+	PreSharedKey    string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
+	KeepAlive       int    `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
+	Secret          string `json:"secret" gorm:"column:secret"`
+	AdTag           string `json:"adTag" gorm:"column:ad_tag;default:''"`
+	LimitIP         int    `json:"limitIp" gorm:"column:limit_ip"`
+	LimitHwid       int    `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
+	TotalGB         int64  `json:"totalGB" gorm:"column:total_gb"`
+	ExpiryTime      int64  `json:"expiryTime" gorm:"column:expiry_time"`
+	Enable          bool   `json:"enable" gorm:"default:true"`
+	TgID            int64  `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
+	Group           string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
+	Comment         string `json:"comment"`
+	Reset           int    `json:"reset" gorm:"default:0"`
+	ResetDay        int    `json:"resetDay" gorm:"column:reset_day;default:0"`
+	ResetMax        int    `json:"resetMax" gorm:"column:reset_max;default:0"`
+	TrafficReset    string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
+	TrafficResetDay int    `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
+	CreatedAt       int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt       int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 	// Owned solely by the node-snapshot sweep, which soft-orphans instead of
 	// deleting; orphans from any other cause stay at zero and are never reaped.
 	SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
@@ -1008,13 +1015,18 @@ func (ClientHwid) TableName() string { return "client_hwids" }
 //   - "subscription": a remote subscription URL. The panel fetches it (cached),
 //     decodes its links, and merges them into the client's subscription.
 type ClientExternalLink struct {
-	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
-	ClientId  int    `json:"clientId" gorm:"index;column:client_id"`
-	Kind      string `json:"kind" gorm:"column:kind"`
-	Value     string `json:"value" gorm:"column:value"`
-	Remark    string `json:"remark" gorm:"column:remark"`
-	SortIndex int    `json:"sortIndex" gorm:"column:sort_index"`
-	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	Id             int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	ClientId       int    `json:"clientId" gorm:"index;column:client_id"`
+	Kind           string `json:"kind" gorm:"column:kind"`
+	Value          string `json:"value" gorm:"column:value"`
+	Remark         string `json:"remark" gorm:"column:remark"`
+	Enable         *bool  `json:"enable" gorm:"column:enable;default:true"`
+	ExpiryTime     int64  `json:"expiryTime" gorm:"column:expiry_time;default:0"`
+	NamePrefix     string `json:"namePrefix" gorm:"column:name_prefix"`
+	LastFetchAt    int64  `json:"lastFetchAt" gorm:"column:last_fetch_at;default:0"`
+	LastFetchError string `json:"lastFetchError" gorm:"column:last_fetch_error"`
+	SortIndex      int    `json:"sortIndex" gorm:"column:sort_index"`
+	CreatedAt      int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 }
 
 func (ClientExternalLink) TableName() string { return "client_external_links" }
@@ -1092,24 +1104,27 @@ func (Host) TableName() string { return "hosts" }
 
 func (c *Client) ToRecord() *ClientRecord {
 	rec := &ClientRecord{
-		Email:      c.Email,
-		SubID:      c.SubID,
-		UUID:       c.ID,
-		Password:   c.Password,
-		Auth:       c.Auth,
-		Flow:       c.Flow,
-		Security:   c.Security,
-		LimitIP:    c.LimitIP,
-		TotalGB:    c.TotalGB,
-		ExpiryTime: c.ExpiryTime,
-		Enable:     c.Enable,
-		TgID:       c.TgID,
-		Group:      c.Group,
-		Comment:    c.Comment,
-		Reset:      c.Reset,
-		ResetMax:   c.ResetMax,
-		CreatedAt:  c.CreatedAt,
-		UpdatedAt:  c.UpdatedAt,
+		Email:           c.Email,
+		SubID:           c.SubID,
+		UUID:            c.ID,
+		Password:        c.Password,
+		Auth:            c.Auth,
+		Flow:            c.Flow,
+		Security:        c.Security,
+		LimitIP:         c.LimitIP,
+		TotalGB:         c.TotalGB,
+		ExpiryTime:      c.ExpiryTime,
+		Enable:          c.Enable,
+		TgID:            c.TgID,
+		Group:           c.Group,
+		Comment:         c.Comment,
+		Reset:           c.Reset,
+		ResetDay:        c.ResetDay,
+		ResetMax:        c.ResetMax,
+		TrafficReset:    c.TrafficReset,
+		TrafficResetDay: c.TrafficResetDay,
+		CreatedAt:       c.CreatedAt,
+		UpdatedAt:       c.UpdatedAt,
 
 		PrivateKey:   c.PrivateKey,
 		PublicKey:    c.PublicKey,
@@ -1146,24 +1161,27 @@ func splitWireguardAllowedIPs(csv string) []string {
 
 func (r *ClientRecord) ToClient() *Client {
 	c := &Client{
-		ID:         r.UUID,
-		Email:      r.Email,
-		SubID:      r.SubID,
-		Password:   r.Password,
-		Auth:       r.Auth,
-		Flow:       r.Flow,
-		Security:   r.Security,
-		LimitIP:    r.LimitIP,
-		TotalGB:    r.TotalGB,
-		ExpiryTime: r.ExpiryTime,
-		Enable:     r.Enable,
-		TgID:       r.TgID,
-		Group:      r.Group,
-		Comment:    r.Comment,
-		Reset:      r.Reset,
-		ResetMax:   r.ResetMax,
-		CreatedAt:  r.CreatedAt,
-		UpdatedAt:  r.UpdatedAt,
+		ID:              r.UUID,
+		Email:           r.Email,
+		SubID:           r.SubID,
+		Password:        r.Password,
+		Auth:            r.Auth,
+		Flow:            r.Flow,
+		Security:        r.Security,
+		LimitIP:         r.LimitIP,
+		TotalGB:         r.TotalGB,
+		ExpiryTime:      r.ExpiryTime,
+		Enable:          r.Enable,
+		TgID:            r.TgID,
+		Group:           r.Group,
+		Comment:         r.Comment,
+		Reset:           r.Reset,
+		ResetDay:        r.ResetDay,
+		ResetMax:        r.ResetMax,
+		TrafficReset:    r.TrafficReset,
+		TrafficResetDay: r.TrafficResetDay,
+		CreatedAt:       r.CreatedAt,
+		UpdatedAt:       r.UpdatedAt,
 
 		PrivateKey:   r.PrivateKey,
 		PublicKey:    r.PublicKey,
@@ -1310,12 +1328,30 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.Reset = incoming.Reset
 		}
 	}
+	if existing.ResetDay != incoming.ResetDay && incoming.ResetDay != 0 {
+		if incomingNewer || existing.ResetDay == 0 {
+			keep("resetDay", existing.ResetDay, incoming.ResetDay, incoming.ResetDay)
+			existing.ResetDay = incoming.ResetDay
+		}
+	}
 	if existing.ResetMax != incoming.ResetMax && incoming.ResetMax != 0 {
 		if incomingNewer || existing.ResetMax == 0 {
 			keep("resetMax", existing.ResetMax, incoming.ResetMax, incoming.ResetMax)
 			existing.ResetMax = incoming.ResetMax
 		}
 	}
+	if existing.TrafficReset != incoming.TrafficReset && incoming.TrafficReset != "" {
+		if incomingNewer || existing.TrafficReset == "" {
+			keep("trafficReset", existing.TrafficReset, incoming.TrafficReset, incoming.TrafficReset)
+			existing.TrafficReset = incoming.TrafficReset
+		}
+	}
+	if existing.TrafficResetDay != incoming.TrafficResetDay && incoming.TrafficResetDay != 0 {
+		if incomingNewer || existing.TrafficResetDay == 0 {
+			keep("trafficResetDay", existing.TrafficResetDay, incoming.TrafficResetDay, incoming.TrafficResetDay)
+			existing.TrafficResetDay = incoming.TrafficResetDay
+		}
+	}
 	if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
 		if incomingNewer || existing.Reverse == "" {
 			keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)

+ 250 - 2
internal/sub/clash_service.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"errors"
 	"fmt"
 	"maps"
 	"strings"
@@ -98,8 +99,15 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	}
 
 	if s.enableRouting {
-		if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
-			return "", "", err
+		resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
+		if resolveErr == nil && strings.TrimSpace(resolved) != "" {
+			if remote {
+				if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
+					return "", "", err
+				}
+			} else if err := mergeClashRulesYAML(config, resolved); err != nil {
+				return "", "", err
+			}
 		}
 	}
 
@@ -814,6 +822,246 @@ func mergeClashRulesYAML(base map[string]any, raw string) error {
 	return nil
 }
 
+// mergeRemoteClashRules lets remote update only the route graph (see
+// remoteClashAllowedKey) and never mutates remote: cached documents are shared.
+func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
+	if len(remote) == 0 {
+		return fmt.Errorf("remote Clash routing source must be a YAML map")
+	}
+
+	for key, value := range remote {
+		if !remoteClashAllowedKey(key) {
+			continue
+		}
+		if err := validateRemoteClashValue(key, value); err != nil {
+			return err
+		}
+		switch key {
+		case "rules":
+			rules, _ := asAnySlice(value)
+			mergeClashRules(base, rules)
+		case "proxy-groups":
+			groups, _ := asAnySlice(value)
+			base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups)
+		default:
+			base[key] = value
+		}
+	}
+	return validateClashRouteGraph(base)
+}
+
+func validateRemoteClashValue(key string, value any) error {
+	switch key {
+	case "rules":
+		rules, ok := asAnySlice(value)
+		if !ok {
+			return fmt.Errorf("remote Clash rules must be a list")
+		}
+		for _, rule := range rules {
+			text, ok := rule.(string)
+			if !ok || strings.TrimSpace(text) == "" {
+				return fmt.Errorf("remote Clash rules must contain non-empty strings")
+			}
+		}
+	case "proxy-groups":
+		groups, ok := asAnySlice(value)
+		if !ok {
+			return fmt.Errorf("remote Clash proxy-groups must be a list")
+		}
+		seen := make(map[string]struct{}, len(groups))
+		for _, groupValue := range groups {
+			group, ok := groupValue.(map[string]any)
+			if !ok {
+				return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
+			}
+			name, nameOK := group["name"].(string)
+			groupType, typeOK := group["type"].(string)
+			if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" {
+				return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
+			}
+			name = strings.TrimSpace(name)
+			if _, duplicate := seen[name]; duplicate {
+				return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name)
+			}
+			seen[name] = struct{}{}
+			if useValue, exists := group["use"]; exists {
+				use, ok := asAnySlice(useValue)
+				if !ok || len(use) > 0 {
+					return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name)
+				}
+			}
+		}
+	case "rule-providers":
+		providers, ok := value.(map[string]any)
+		if !ok {
+			return fmt.Errorf("remote Clash rule-providers must be a map")
+		}
+		for name, provider := range providers {
+			if strings.TrimSpace(name) == "" {
+				return fmt.Errorf("remote Clash rule-provider name must not be empty")
+			}
+			if _, ok := provider.(map[string]any); !ok {
+				return fmt.Errorf("remote Clash rule-provider %q must be a map", name)
+			}
+		}
+	}
+	return nil
+}
+
+func remoteClashAllowedKey(key string) bool {
+	switch key {
+	case "proxy-groups", "rule-providers", "rules":
+		return true
+	default:
+		return false
+	}
+}
+
+func validateClashRouteGraph(config map[string]any) error {
+	known := map[string]struct{}{
+		"DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
+	}
+	if proxies, ok := asAnySlice(config["proxies"]); ok {
+		for _, value := range proxies {
+			proxy, ok := value.(map[string]any)
+			if !ok {
+				continue
+			}
+			if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" {
+				known[strings.TrimSpace(name)] = struct{}{}
+			}
+		}
+	}
+
+	groups, _ := asAnySlice(config["proxy-groups"])
+	for _, value := range groups {
+		if name := clashProxyGroupName(value); name != "" {
+			known[name] = struct{}{}
+		}
+	}
+	for _, value := range groups {
+		group, ok := value.(map[string]any)
+		if !ok {
+			continue
+		}
+		name := clashProxyGroupName(group)
+		refs, exists := group["proxies"]
+		if !exists {
+			continue
+		}
+		proxies, ok := asAnySlice(refs)
+		if !ok {
+			return fmt.Errorf("Clash proxy-group %q proxies must be a list", name)
+		}
+		for _, refValue := range proxies {
+			ref, ok := refValue.(string)
+			if !ok || strings.TrimSpace(ref) == "" {
+				return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name)
+			}
+			ref = strings.TrimSpace(ref)
+			if _, exists := known[ref]; !exists {
+				return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref)
+			}
+		}
+	}
+
+	providers, _ := config["rule-providers"].(map[string]any)
+	for providerName, value := range providers {
+		provider, ok := value.(map[string]any)
+		if !ok {
+			continue
+		}
+		via, ok := provider["proxy"].(string)
+		if !ok || strings.TrimSpace(via) == "" {
+			continue
+		}
+		via = strings.TrimSpace(via)
+		if _, exists := known[via]; !exists {
+			return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via)
+		}
+	}
+
+	rules, _ := asAnySlice(config["rules"])
+	for _, value := range rules {
+		rule, ok := value.(string)
+		if !ok || strings.TrimSpace(rule) == "" {
+			return errors.New("Clash rules must contain non-empty strings")
+		}
+		parts := strings.Split(rule, ",")
+		for i := range parts {
+			parts[i] = strings.TrimSpace(parts[i])
+		}
+		if len(parts) < 2 {
+			return fmt.Errorf("invalid Clash rule %q", rule)
+		}
+		if strings.EqualFold(parts[0], "RULE-SET") {
+			if len(parts) < 3 {
+				return fmt.Errorf("invalid Clash RULE-SET rule %q", rule)
+			}
+			if _, exists := providers[parts[1]]; !exists {
+				return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1])
+			}
+		}
+		targetIndex := len(parts) - 1
+		// Mihomo IP rules may carry trailing no-resolve / src option flags.
+		for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) {
+			targetIndex--
+		}
+		if targetIndex < 1 {
+			return fmt.Errorf("invalid Clash rule target in %q", rule)
+		}
+		target := parts[targetIndex]
+		if _, exists := known[target]; !exists {
+			return fmt.Errorf("Clash rule references unknown proxy or group %q", target)
+		}
+	}
+	return nil
+}
+
+func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any {
+	baseGroups, _ := asAnySlice(baseValue)
+	baseByName := make(map[string]any, len(baseGroups))
+	baseOrder := make([]string, 0, len(baseGroups))
+	for _, group := range baseGroups {
+		name := clashProxyGroupName(group)
+		if name == "" {
+			continue
+		}
+		baseByName[name] = group
+		baseOrder = append(baseOrder, name)
+	}
+
+	merged := make([]any, 0, len(remoteGroups)+len(baseGroups))
+	seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups))
+	for _, group := range remoteGroups {
+		name := clashProxyGroupName(group)
+		if name == "" {
+			continue
+		}
+		if _, duplicate := seen[name]; duplicate {
+			continue
+		}
+		seen[name] = struct{}{}
+		merged = append(merged, group)
+	}
+	for _, name := range baseOrder {
+		if _, replaced := seen[name]; replaced {
+			continue
+		}
+		merged = append(merged, baseByName[name])
+	}
+	return merged
+}
+
+func clashProxyGroupName(value any) string {
+	group, ok := value.(map[string]any)
+	if !ok {
+		return ""
+	}
+	name, _ := group["name"].(string)
+	return strings.TrimSpace(name)
+}
+
 func mergeClashRules(base map[string]any, customRules []any) {
 	if len(customRules) == 0 {
 		return

+ 10 - 5
internal/sub/controller.go

@@ -422,8 +422,11 @@ func (a *SUBController) subs(c *gin.Context) {
 		a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
 		if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
-			result.WriteString(a.subIncyRoutingRules)
-			result.WriteString("\n")
+			incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
+			if err == nil && strings.TrimSpace(incyRules) != "" {
+				result.WriteString(incyRules)
+				result.WriteString("\n")
+			}
 		}
 
 		if a.subEncrypt {
@@ -828,12 +831,14 @@ func (a *SUBController) ApplyCommonHeaders(
 		c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
 	}
 
-	// Advanced (Happ)
+	// Advanced (Happ). Routing stays independent of the enable flag; remote
+	// values come only from the validated cache and never delay this response.
+	rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
 	if profileEnableRouting {
 		c.Writer.Header().Set("Routing-Enable", "true")
 	}
-	if profileRoutingRules != "" {
-		c.Writer.Header().Set("Routing", profileRoutingRules)
+	if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
+		c.Writer.Header().Set("Routing", rules)
 	}
 	if profileHideSettings {
 		c.Writer.Header().Set("Hide-Settings", "1")

+ 38 - 19
internal/sub/external_config.go

@@ -4,6 +4,7 @@ import (
 	"encoding/base64"
 	"net/url"
 	"strings"
+	"time"
 
 	"github.com/goccy/go-json"
 
@@ -16,11 +17,12 @@ import (
 // externalLinkEntry is one client × external-link row, resolved for a
 // subscription request. Email/Enable come from the owning client.
 type externalLinkEntry struct {
-	Kind   string
-	Value  string
-	Remark string
-	Email  string
-	Enable bool
+	Kind       string
+	Value      string
+	Remark     string
+	NamePrefix string
+	Email      string
+	Enable     bool
 }
 
 // expandedLink is a single share link contributed by an entry, with the display
@@ -50,7 +52,10 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 	}
 
 	var rows []model.ClientExternalLink
+	now := time.Now().UnixMilli()
 	if err := db.Where("client_id IN ?", clientIds).
+		Where("(enable IS NULL OR enable = ?)", true).
+		Where("(expiry_time IS NULL OR expiry_time <= 0 OR expiry_time > ?)", now).
 		Order("client_id ASC, sort_index ASC, id ASC").
 		Find(&rows).Error; err != nil {
 		return nil, err
@@ -63,27 +68,28 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 	for _, r := range rows {
 		rec := byId[r.ClientId]
 		out = append(out, externalLinkEntry{
-			Kind:   r.Kind,
-			Value:  r.Value,
-			Remark: r.Remark,
-			Email:  rec.Email,
-			Enable: rec.Enable,
+			Kind:       r.Kind,
+			Value:      r.Value,
+			Remark:     r.Remark,
+			NamePrefix: r.NamePrefix,
+			Email:      rec.Email,
+			Enable:     rec.Enable,
 		})
 	}
 	return out, nil
 }
 
-// expandEntry turns one entry into the concrete share links it contributes. A
-// "subscription" entry is fetched (cached) and its links keep their own names
-// (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
-// otherwise the link's original name — never blank, so Clash/JSON do not fall
-// back to the client email.
+// expandEntry turns one entry into the concrete share links it contributes.
+// Names are never blank, so Clash/JSON do not fall back to the client email.
 func expandEntry(e externalLinkEntry) []expandedLink {
 	if e.Kind == model.ExternalLinkKindSubscription {
-		links := fetchSubscriptionLinks(e.Value)
-		out := make([]expandedLink, 0, len(links))
-		for _, l := range links {
-			out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
+		res := fetchSubscriptionLinks(e.Value)
+		if res.fetched {
+			recordExternalSubscriptionFetch(e.Value, res.err)
+		}
+		out := make([]expandedLink, 0, len(res.links))
+		for _, l := range res.links {
+			out = append(out, expandedLink{Link: l, Name: prefixedLinkName(linkDisplayName(l), e.NamePrefix, e.Email)})
 		}
 		return out
 	}
@@ -129,6 +135,19 @@ func linkDisplayName(rawLink string) string {
 	return ""
 }
 
+// prefixedLinkName falls back to the client email so a prefixed row never
+// renders as the bare prefix when the link carries no name of its own.
+func prefixedLinkName(displayName, prefix, fallback string) string {
+	if strings.TrimSpace(prefix) == "" {
+		return displayName
+	}
+	name := displayName
+	if name == "" {
+		name = strings.TrimSpace(fallback)
+	}
+	return prefix + name
+}
+
 // applyRemarkToLink rewrites a share link's display name to remark (when set),
 // leaving everything else byte-for-byte. vmess carries its remark in the base64
 // JSON `ps`; every other scheme carries it in the URL #fragment.

+ 26 - 0
internal/sub/external_config_test.go

@@ -5,6 +5,7 @@ import (
 	"net/url"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/goccy/go-json"
 
@@ -98,6 +99,31 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
 	}
 }
 
+func TestExpandEntrySubscriptionAppliesNamePrefix(t *testing.T) {
+	const subURL = "https://provider.example/sub-prefix"
+	subscriptionCache.Lock()
+	subscriptionCache.m[subURL] = subscriptionCacheEntry{
+		links:     []string{"trojan://[email protected]:8443#HK-01"},
+		fetchedAt: time.Now(),
+	}
+	subscriptionCache.Unlock()
+	t.Cleanup(func() {
+		subscriptionCache.Lock()
+		delete(subscriptionCache.m, subURL)
+		subscriptionCache.Unlock()
+	})
+
+	got := expandEntry(externalLinkEntry{
+		Kind:       model.ExternalLinkKindSubscription,
+		Value:      subURL,
+		NamePrefix: "[zjh] ",
+		Email:      "zjh",
+	})
+	if len(got) != 1 || got[0].Name != "[zjh] HK-01" {
+		t.Fatalf("expandEntry = %#v", got)
+	}
+}
+
 func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
 	got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:8443#orig", Remark: ""})
 	if len(got) != 1 || got[0].Name != "orig" {

+ 40 - 6
internal/sub/external_subscription.go

@@ -8,6 +8,10 @@ import (
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // External subscription fetching: a "subscription" external link is a remote
@@ -42,26 +46,34 @@ var subscriptionCache = struct {
 	inflight: make(map[string]*subscriptionFetch),
 }
 
+// subscriptionFetchResult reports whether this caller performed the network
+// fetch, so only it records status and cache hits stay read-only.
+type subscriptionFetchResult struct {
+	links   []string
+	fetched bool
+	err     error
+}
+
 // fetchSubscriptionLinks returns the share links contained in a remote
 // subscription URL, using a short-lived cache. On any failure it returns the
 // last cached value (if present) or nil — never an error, so the rest of the
 // client's subscription still renders.
-func fetchSubscriptionLinks(rawURL string) []string {
+func fetchSubscriptionLinks(rawURL string) subscriptionFetchResult {
 	rawURL = strings.TrimSpace(rawURL)
 	if rawURL == "" {
-		return nil
+		return subscriptionFetchResult{}
 	}
 
 	subscriptionCache.Lock()
 	cached, ok := subscriptionCache.m[rawURL]
 	if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
 		subscriptionCache.Unlock()
-		return cached.links
+		return subscriptionFetchResult{links: cached.links}
 	}
 	if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
 		subscriptionCache.Unlock()
 		<-fetch.done
-		return fetch.links
+		return subscriptionFetchResult{links: fetch.links}
 	}
 	fetch := &subscriptionFetch{done: make(chan struct{})}
 	subscriptionCache.inflight[rawURL] = fetch
@@ -78,7 +90,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
 		if ok {
 			fetch.links = cached.links
 		}
-		return fetch.links
+		return subscriptionFetchResult{links: fetch.links, fetched: true, err: err}
 	}
 
 	subscriptionCache.Lock()
@@ -86,7 +98,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
 	trimSubscriptionCacheLocked(rawURL)
 	subscriptionCache.Unlock()
 	fetch.links = links
-	return fetch.links
+	return subscriptionFetchResult{links: links, fetched: true}
 }
 
 func trimSubscriptionCacheLocked(keep string) {
@@ -109,6 +121,28 @@ func trimSubscriptionCacheLocked(keep string) {
 	}
 }
 
+// recordExternalSubscriptionFetch stamps status on every row holding this URL,
+// keyed by value because row ids churn on save and the cache is per URL.
+func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
+	rawURL = strings.TrimSpace(rawURL)
+	if rawURL == "" {
+		return
+	}
+	lastFetchError := ""
+	if fetchErr != nil {
+		lastFetchError = fetchErr.Error()
+	}
+	if err := database.GetDB().
+		Model(&model.ClientExternalLink{}).
+		Where("kind = ? AND value = ?", model.ExternalLinkKindSubscription, rawURL).
+		Updates(map[string]any{
+			"last_fetch_at":    time.Now().UnixMilli(),
+			"last_fetch_error": lastFetchError,
+		}).Error; err != nil {
+		logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
+	}
+}
+
 func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
 	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
 	if err != nil {

+ 124 - 4
internal/sub/external_subscription_test.go

@@ -10,6 +10,9 @@ import (
 	"sync/atomic"
 	"testing"
 	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func resetSubscriptionCache(t *testing.T) {
@@ -44,7 +47,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
 	var wg sync.WaitGroup
 	for range callers {
 		wg.Go(func() {
-			results <- fetchSubscriptionLinks(srv.URL)
+			results <- fetchSubscriptionLinks(srv.URL).links
 		})
 	}
 
@@ -73,7 +76,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
 	defer srv.Close()
 
 	for i := range subscriptionCacheCapacity + 1 {
-		links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
+		links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i)).links
 		if len(links) != 1 {
 			t.Fatalf("links at %d = %#v", i, links)
 		}
@@ -122,12 +125,12 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
 	var wg sync.WaitGroup
 	for range callers {
 		wg.Go(func() {
-			results <- fetchSubscriptionLinks(staleURL)
+			results <- fetchSubscriptionLinks(staleURL).links
 		})
 	}
 
 	time.Sleep(100 * time.Millisecond)
-	if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://[email protected]:443" {
+	if links := fetchSubscriptionLinks(srv.URL + "/fresh").links; len(links) != 1 || links[0] != "vless://[email protected]:443" {
 		t.Fatalf("fresh links = %#v", links)
 	}
 	close(release)
@@ -178,3 +181,120 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
 		t.Fatalf("links = %v, want [%q]", links, link)
 	}
 }
+
+func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
+	initMutDB(t)
+	resetSubscriptionCache(t)
+	db := database.GetDB()
+
+	var failing atomic.Bool
+	failing.Store(true)
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if failing.Load() {
+			w.WriteHeader(http.StatusBadGateway)
+			return
+		}
+		_, _ = w.Write([]byte("vless://[email protected]:443#Node"))
+	}))
+	defer srv.Close()
+
+	owners := []model.ClientRecord{
+		{Email: "[email protected]", SubID: "sub-fetch", UUID: "uuid-1", Enable: true},
+		{Email: "[email protected]", SubID: "sub-fetch", UUID: "uuid-2", Enable: true},
+	}
+	for i := range owners {
+		if err := db.Create(&owners[i]).Error; err != nil {
+			t.Fatalf("seed client %d: %v", i, err)
+		}
+		row := model.ClientExternalLink{
+			ClientId: owners[i].Id,
+			Kind:     model.ExternalLinkKindSubscription,
+			Value:    srv.URL,
+		}
+		if err := db.Create(&row).Error; err != nil {
+			t.Fatalf("seed external link %d: %v", i, err)
+		}
+	}
+
+	svc := NewSubService("")
+	entries, err := svc.getClientExternalLinksBySubId("sub-fetch")
+	if err != nil {
+		t.Fatalf("getClientExternalLinksBySubId: %v", err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("entries = %d, want 2", len(entries))
+	}
+
+	for _, e := range entries {
+		expandEntry(e)
+	}
+
+	var rows []model.ClientExternalLink
+	if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil {
+		t.Fatalf("read rows: %v", err)
+	}
+	if len(rows) != 2 {
+		t.Fatalf("rows = %d, want 2", len(rows))
+	}
+	for _, row := range rows {
+		if row.LastFetchAt <= 0 {
+			t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt)
+		}
+		if row.LastFetchError != errBadStatus.Error() {
+			t.Fatalf("row %d lastFetchError = %q, want %q", row.Id, row.LastFetchError, errBadStatus)
+		}
+	}
+
+	failing.Store(false)
+	resetSubscriptionCache(t)
+	for _, e := range entries {
+		expandEntry(e)
+	}
+
+	if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil {
+		t.Fatalf("re-read rows: %v", err)
+	}
+	for _, row := range rows {
+		if row.LastFetchError != "" {
+			t.Fatalf("row %d lastFetchError = %q, want cleared after a good fetch", row.Id, row.LastFetchError)
+		}
+		if row.LastFetchAt <= 0 {
+			t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt)
+		}
+	}
+}
+
+func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
+	initMutDB(t)
+	resetSubscriptionCache(t)
+	db := database.GetDB()
+
+	const subURL = "https://provider.example/cached"
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-cached", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	row := model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindSubscription, Value: subURL}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatalf("seed external link: %v", err)
+	}
+
+	subscriptionCache.Lock()
+	subscriptionCache.m[subURL] = subscriptionCacheEntry{
+		links:     []string{"vless://[email protected]:443#Node"},
+		fetchedAt: time.Now(),
+	}
+	subscriptionCache.Unlock()
+
+	if got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindSubscription, Value: subURL}); len(got) != 1 {
+		t.Fatalf("expandEntry = %#v, want the cached link", got)
+	}
+
+	var after model.ClientExternalLink
+	if err := db.First(&after, row.Id).Error; err != nil {
+		t.Fatalf("read row: %v", err)
+	}
+	if after.LastFetchAt != 0 || after.LastFetchError != "" {
+		t.Fatalf("cache hit wrote fetch status: %#v", after)
+	}
+}

+ 11 - 0
internal/sub/mutation_audit_test.go

@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -24,6 +25,10 @@ func initMutDB(t *testing.T) {
 	t.Cleanup(func() { _ = database.CloseDB() })
 }
 
+func externalLinkEnabled(v bool) *bool {
+	return &v
+}
+
 // --- json_service.go:40 — rules are merged into routing only when non-empty ---
 
 func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
@@ -307,6 +312,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
 	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
 		t.Fatalf("seed link a: %v", err)
 	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: externalLinkEnabled(false), SortIndex: 3}).Error; err != nil {
+		t.Fatalf("seed disabled link: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://expired", Remark: "expired", ExpiryTime: time.Now().Add(-time.Hour).UnixMilli(), SortIndex: 4}).Error; err != nil {
+		t.Fatalf("seed expired link: %v", err)
+	}
 
 	out, err = s.getClientExternalLinksBySubId("sub-ok")
 	if err != nil {

+ 5 - 0
internal/sub/remark_vars.go

@@ -263,6 +263,11 @@ func remarkVarValue(token string, ctx remarkContext) string {
 			return strconv.Itoa(c.Reset)
 		}
 		return ""
+	case "RESET_DAY":
+		if c.ResetDay > 0 {
+			return strconv.Itoa(c.ResetDay)
+		}
+		return ""
 	case "STATUS_EMOJI":
 		return statusEmoji(st)
 	case "USAGE_PERCENTAGE":

+ 623 - 0
internal/sub/remote_routing.go

@@ -0,0 +1,623 @@
+package sub
+
+import (
+	"context"
+	"crypto/tls"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+	"time"
+
+	yaml "github.com/goccy/go-yaml"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+)
+
+// Remote sources reuse the existing settings fields (one HTTPS URL = remote,
+// else inline) so no second mode toggle can disagree with the field contents.
+
+type remoteRoutingKind string
+
+const (
+	remoteRoutingHapp  remoteRoutingKind = "happ"
+	remoteRoutingClash remoteRoutingKind = "clash"
+
+	remoteRoutingCacheTTL     = 10 * time.Minute
+	remoteRoutingRetryDelay   = 30 * time.Second
+	remoteRoutingHTTPTimeout  = 6 * time.Second
+	remoteRoutingHappMaxBody  = 16 << 10 // 16 KiB; Happ emits the result in a response header
+	remoteRoutingHappMaxValue = 8 << 10  // normalized Routing header value
+	remoteRoutingClashMaxBody = 2 << 20  // 2 MiB
+)
+
+var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
+
+type remoteRoutingKey struct {
+	kind   remoteRoutingKind
+	source string
+}
+
+type remoteRoutingCacheEntry struct {
+	Source       string         `json:"source"`
+	Content      string         `json:"content"`
+	FetchedAt    int64          `json:"fetchedAt"`
+	ETag         string         `json:"etag,omitempty"`
+	LastModified string         `json:"lastModified,omitempty"`
+	Clash        map[string]any `json:"-"`
+}
+
+func (e remoteRoutingCacheEntry) fetchedTime() time.Time {
+	return time.Unix(e.FetchedAt, 0)
+}
+
+type remoteRoutingFetch struct {
+	done chan struct{}
+	err  error
+}
+
+type remoteRoutingResolver struct {
+	mu           sync.Mutex
+	loadMu       sync.Mutex
+	loaded       bool
+	loadInFlight bool
+	entries      map[remoteRoutingKey]remoteRoutingCacheEntry
+	inflight     map[remoteRoutingKey]*remoteRoutingFetch
+	lastAttempt  map[remoteRoutingKey]time.Time
+	client       *http.Client
+	now          func() time.Time
+	persist      bool
+}
+
+func newRemoteRoutingResolver(client *http.Client, persist bool) *remoteRoutingResolver {
+	return &remoteRoutingResolver{
+		entries:     make(map[remoteRoutingKey]remoteRoutingCacheEntry),
+		inflight:    make(map[remoteRoutingKey]*remoteRoutingFetch),
+		lastAttempt: make(map[remoteRoutingKey]time.Time),
+		client:      client,
+		now:         time.Now,
+		persist:     persist,
+	}
+}
+
+var routingSourceResolver = newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), true)
+
+// resolveRoutingSource serves a remote source from the validated cache without
+// ever blocking on network; inline values pass through (bool reports remote).
+func resolveRoutingSource(kind remoteRoutingKind, raw string) (string, bool, error) {
+	return routingSourceResolver.resolve(kind, raw)
+}
+
+func (r *remoteRoutingResolver) resolve(kind remoteRoutingKind, raw string) (string, bool, error) {
+	entry, remote, err := r.resolveEntry(kind, raw)
+	if !remote {
+		return raw, false, err
+	}
+	return entry.Content, true, err
+}
+
+func resolveClashRoutingSource(raw string) (string, map[string]any, bool, error) {
+	entry, remote, err := routingSourceResolver.resolveEntry(remoteRoutingClash, raw)
+	if !remote {
+		return raw, nil, false, err
+	}
+	return entry.Content, entry.Clash, true, err
+}
+
+func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string) (remoteRoutingCacheEntry, bool, error) {
+	source, remote, err := common.ParseRemoteRoutingURL(raw)
+	if err != nil {
+		return remoteRoutingCacheEntry{}, true, err
+	}
+	if !remote {
+		return remoteRoutingCacheEntry{}, false, nil
+	}
+
+	r.triggerPersistedLoad()
+
+	key := remoteRoutingKey{kind: kind, source: source}
+	now := r.now()
+
+	r.mu.Lock()
+	cached, hasCached := r.entries[key]
+	if hasCached && now.Sub(cached.fetchedTime()) < remoteRoutingCacheTTL {
+		r.mu.Unlock()
+		return cached, true, nil
+	}
+
+	if _, ok := r.inflight[key]; ok {
+		r.mu.Unlock()
+		if hasCached {
+			return cached, true, nil
+		}
+		return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
+	}
+
+	if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
+		r.mu.Unlock()
+		if hasCached {
+			return cached, true, nil
+		}
+		return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
+	}
+
+	fetch := &remoteRoutingFetch{done: make(chan struct{})}
+	r.inflight[key] = fetch
+	r.mu.Unlock()
+
+	common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
+	if hasCached {
+		return cached, true, nil
+	}
+	return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
+}
+
+// RefreshRemoteRoutingSources warms and refreshes configured remote sources
+// from the cron job. Concurrent resolver reads are safe; fetches coalesce.
+func RefreshRemoteRoutingSources(happ, clash string) {
+	for kind, raw := range map[remoteRoutingKind]string{
+		remoteRoutingHapp:  happ,
+		remoteRoutingClash: clash,
+	} {
+		_, remote, parseErr := common.ParseRemoteRoutingURL(raw)
+		if parseErr != nil {
+			logger.Warningf("Remote %s routing source is invalid", kind)
+			continue
+		}
+		if remote {
+			_ = routingSourceResolver.refreshSource(kind, raw)
+		}
+	}
+}
+
+func (r *remoteRoutingResolver) refreshSource(kind remoteRoutingKind, raw string) error {
+	source, remote, err := common.ParseRemoteRoutingURL(raw)
+	if err != nil || !remote {
+		return err
+	}
+	r.ensurePersistedLoaded()
+
+	key := remoteRoutingKey{kind: kind, source: source}
+	now := r.now()
+	r.mu.Lock()
+	previous, hasPrevious := r.entries[key]
+	if hasPrevious && now.Sub(previous.fetchedTime()) < remoteRoutingCacheTTL {
+		r.mu.Unlock()
+		return nil
+	}
+	if fetch, ok := r.inflight[key]; ok {
+		done := fetch.done
+		r.mu.Unlock()
+		<-done
+		return fetch.err
+	}
+	if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
+		r.mu.Unlock()
+		return errRemoteRoutingUnavailable
+	}
+	fetch := &remoteRoutingFetch{done: make(chan struct{})}
+	r.inflight[key] = fetch
+	r.mu.Unlock()
+
+	r.refresh(key, previous, hasPrevious, fetch)
+	return fetch.err
+}
+
+func (r *remoteRoutingResolver) refresh(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool, fetch *remoteRoutingFetch) {
+	entry, err := r.fetch(key, previous, hasPrevious)
+	now := r.now()
+
+	r.mu.Lock()
+	r.lastAttempt[key] = now
+	if err == nil {
+		r.entries[key] = entry
+	}
+	fetch.err = err
+	delete(r.inflight, key)
+	close(fetch.done)
+	r.mu.Unlock()
+
+	if err != nil {
+		if hasPrevious {
+			logger.Warningf("Remote %s routing refresh from %s failed; keeping the last valid value", key.kind, remoteRoutingHost(key.source))
+		} else {
+			logger.Warningf("Remote %s routing refresh from %s failed; no validated value is cached", key.kind, remoteRoutingHost(key.source))
+		}
+		return
+	}
+	if r.persist {
+		r.persistEntry(key.kind, entry)
+	}
+}
+
+func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool) (entry remoteRoutingCacheEntry, err error) {
+	// Remote bytes reach the YAML/JSON parsers below; a parser panic must
+	// degrade to a failed refresh (keeping last-good), not crash the panel.
+	defer func() {
+		if panicValue := recover(); panicValue != nil {
+			entry, err = remoteRoutingCacheEntry{}, fmt.Errorf("remote routing fetch panicked: %v", panicValue)
+		}
+	}()
+
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, key.source, nil)
+	if err != nil {
+		return remoteRoutingCacheEntry{}, err
+	}
+	req.Header.Set("User-Agent", "3x-ui-remote-routing/1.0")
+	if hasPrevious {
+		if previous.ETag != "" {
+			req.Header.Set("If-None-Match", previous.ETag)
+		}
+		if previous.LastModified != "" {
+			req.Header.Set("If-Modified-Since", previous.LastModified)
+		}
+	}
+
+	resp, err := r.client.Do(req)
+	if err != nil {
+		return remoteRoutingCacheEntry{}, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusNotModified {
+		if !hasPrevious {
+			return remoteRoutingCacheEntry{}, errors.New("remote source returned 304 without a cached value")
+		}
+		previous.FetchedAt = r.now().Unix()
+		if etag := strings.TrimSpace(resp.Header.Get("ETag")); etag != "" {
+			previous.ETag = etag
+		}
+		if modified := strings.TrimSpace(resp.Header.Get("Last-Modified")); modified != "" {
+			previous.LastModified = modified
+		}
+		return previous, nil
+	}
+	if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
+		location := strings.TrimSpace(resp.Header.Get("Location"))
+		content, locationErr := normalizeHappRouting([]byte(location))
+		if locationErr != nil {
+			return remoteRoutingCacheEntry{}, fmt.Errorf("invalid Happ redirect target: %w", locationErr)
+		}
+		if len(content) > remoteRoutingHappMaxValue {
+			return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
+		}
+		return remoteRoutingCacheEntry{
+			Source:    key.source,
+			Content:   content,
+			FetchedAt: r.now().Unix(),
+		}, nil
+	}
+	if resp.StatusCode != http.StatusOK {
+		return remoteRoutingCacheEntry{}, fmt.Errorf("remote source returned HTTP %d", resp.StatusCode)
+	}
+
+	limit := int64(remoteRoutingHappMaxBody)
+	if key.kind == remoteRoutingClash {
+		limit = remoteRoutingClashMaxBody
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
+	if err != nil {
+		return remoteRoutingCacheEntry{}, err
+	}
+	if int64(len(body)) > limit {
+		return remoteRoutingCacheEntry{}, errors.New("remote routing response exceeds the size limit")
+	}
+
+	content, clash, err := normalizeRemoteRoutingContent(key.kind, body)
+	if err != nil {
+		return remoteRoutingCacheEntry{}, err
+	}
+	if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
+		return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
+	}
+	return remoteRoutingCacheEntry{
+		Source:       key.source,
+		Content:      content,
+		FetchedAt:    r.now().Unix(),
+		ETag:         strings.TrimSpace(resp.Header.Get("ETag")),
+		LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")),
+		Clash:        clash,
+	}, nil
+}
+
+func isRemoteHappRedirect(status int) bool {
+	switch status {
+	case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
+		http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
+		return true
+	default:
+		return false
+	}
+}
+
+func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
+	switch kind {
+	case remoteRoutingHapp:
+		content, err := normalizeHappRouting(body)
+		return content, nil, err
+	case remoteRoutingClash:
+		return normalizeClashRouting(body)
+	default:
+		return "", nil, fmt.Errorf("unsupported remote routing kind %q", kind)
+	}
+}
+
+func normalizeHappRouting(body []byte) (string, error) {
+	text := strings.TrimSpace(string(body))
+	if text == "" {
+		return "", errors.New("empty Happ routing response")
+	}
+
+	if strings.HasPrefix(text, "{") {
+		compact, err := validateAndCompactJSONObject([]byte(text))
+		if err != nil {
+			return "", fmt.Errorf("invalid Happ routing JSON: %w", err)
+		}
+		return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
+	}
+	if strings.ContainsAny(text, "\r\n") {
+		return "", errors.New("Happ deeplink must be a single line")
+	}
+
+	payload := ""
+	for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/"} {
+		if strings.HasPrefix(text, prefix) {
+			payload = strings.TrimPrefix(text, prefix)
+			break
+		}
+	}
+	if payload == "" {
+		return "", errors.New("Happ response is neither routing JSON nor a routing deeplink")
+	}
+	decoded, err := decodeRoutingBase64(payload)
+	if err != nil {
+		return "", fmt.Errorf("invalid Happ routing payload: %w", err)
+	}
+	if _, err := validateAndCompactJSONObject(decoded); err != nil {
+		return "", fmt.Errorf("invalid Happ routing payload JSON: %w", err)
+	}
+	return text, nil
+}
+
+func validateAndCompactJSONObject(raw []byte) ([]byte, error) {
+	var object map[string]any
+	if err := json.Unmarshal(raw, &object); err != nil {
+		return nil, err
+	}
+	if object == nil {
+		return nil, errors.New("expected a JSON object")
+	}
+	return json.Marshal(object)
+}
+
+func decodeRoutingBase64(value string) ([]byte, error) {
+	value = strings.TrimSpace(value)
+	encodings := []*base64.Encoding{
+		base64.StdEncoding,
+		base64.RawStdEncoding,
+		base64.URLEncoding,
+		base64.RawURLEncoding,
+	}
+	var lastErr error
+	for _, encoding := range encodings {
+		decoded, err := encoding.DecodeString(value)
+		if err == nil {
+			return decoded, nil
+		}
+		lastErr = err
+	}
+	return nil, lastErr
+}
+
+func normalizeClashRouting(body []byte) (string, map[string]any, error) {
+	text := strings.TrimSpace(string(body))
+	if text == "" {
+		return "", nil, errors.New("empty Clash routing response")
+	}
+	var document map[string]any
+	if err := yaml.Unmarshal([]byte(text), &document); err != nil {
+		return "", nil, fmt.Errorf("invalid Clash routing YAML: %w", err)
+	}
+	if len(document) == 0 {
+		return "", nil, errors.New("Clash routing response must be a YAML map")
+	}
+	hasSupportedKey := false
+	for key := range document {
+		if remoteClashAllowedKey(key) {
+			hasSupportedKey = true
+			break
+		}
+	}
+	if !hasSupportedKey {
+		return "", nil, errors.New("Clash routing response has no supported routing keys")
+	}
+	base := map[string]any{
+		"proxies": []map[string]any{{"name": "validation-node", "type": "vless"}},
+		"proxy-groups": []map[string]any{{
+			"name": "PROXY", "type": "select", "proxies": []string{"validation-node", "DIRECT"},
+		}},
+		"rules": []string{"MATCH,PROXY"},
+	}
+	if err := mergeRemoteClashRules(base, document); err != nil {
+		return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err)
+	}
+	return text, document, nil
+}
+
+func resolveIncyRoutingSource(raw string) (string, bool, error) {
+	source, remote, err := common.ParseRemoteRoutingURL(raw)
+	if err != nil || !remote {
+		return raw, remote, err
+	}
+	return "incy://autorouting/onadd/" + source, true, nil
+}
+
+func newRemoteRoutingHTTPClient() *http.Client {
+	transport := &http.Transport{
+		Proxy:                 nil,
+		DialContext:           netsafe.SSRFGuardedDialContext,
+		ForceAttemptHTTP2:     true,
+		TLSHandshakeTimeout:   4 * time.Second,
+		ResponseHeaderTimeout: 5 * time.Second,
+		TLSClientConfig:       &tls.Config{MinVersion: tls.VersionTLS12},
+	}
+	return &http.Client{
+		Timeout:       remoteRoutingHTTPTimeout,
+		Transport:     transport,
+		CheckRedirect: checkRemoteRoutingRedirect,
+	}
+}
+
+func checkRemoteRoutingRedirect(req *http.Request, via []*http.Request) error {
+	if len(via) >= 5 {
+		return errors.New("stopped after 5 redirects")
+	}
+	if strings.EqualFold(req.URL.Scheme, "happ") {
+		// routing.help-style services publish the deeplink as the final Location;
+		// hand the 3xx back to fetch(), which validates it without a request.
+		return http.ErrUseLastResponse
+	}
+	if !strings.EqualFold(req.URL.Scheme, "https") || req.URL.Hostname() == "" || req.URL.User != nil {
+		return errors.New("remote routing redirect must stay on an absolute HTTPS URL")
+	}
+	// The guarded dialer re-resolves, validates and connects to the same public
+	// address, including on every HTTPS redirect hop.
+	return nil
+}
+
+func remoteRoutingHost(source string) string {
+	u, err := url.Parse(source)
+	if err != nil || u.Hostname() == "" {
+		return "unknown host"
+	}
+	return u.Hostname()
+}
+
+func remoteRoutingSettingKey(kind remoteRoutingKind) string {
+	return "_subRemoteRoutingCache_" + string(kind)
+}
+
+func (r *remoteRoutingResolver) ensurePersistedLoaded() {
+	if !r.persist {
+		return
+	}
+	r.mu.Lock()
+	loaded := r.loaded
+	r.mu.Unlock()
+	if loaded {
+		return
+	}
+
+	r.loadMu.Lock()
+	defer r.loadMu.Unlock()
+	r.mu.Lock()
+	loaded = r.loaded
+	r.mu.Unlock()
+	if loaded {
+		return
+	}
+	db := database.GetDB()
+	if db == nil {
+		return
+	}
+	sqlDB, err := db.DB()
+	if err != nil || sqlDB.Ping() != nil {
+		return
+	}
+	r.loadPersisted()
+	r.mu.Lock()
+	r.loaded = true
+	r.mu.Unlock()
+}
+
+// triggerPersistedLoad keeps SQLite off the subscription request path: requests
+// schedule at most one background load; the startup job loads synchronously.
+func (r *remoteRoutingResolver) triggerPersistedLoad() {
+	if !r.persist {
+		return
+	}
+	r.mu.Lock()
+	if r.loaded || r.loadInFlight {
+		r.mu.Unlock()
+		return
+	}
+	r.loadInFlight = true
+	r.mu.Unlock()
+
+	common.GoRecover("remote-routing-cache-load", func() {
+		defer func() {
+			r.mu.Lock()
+			r.loadInFlight = false
+			r.mu.Unlock()
+		}()
+		r.ensurePersistedLoaded()
+	})
+}
+
+func (r *remoteRoutingResolver) loadPersisted() {
+	loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
+	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
+		var setting model.Setting
+		err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
+		if err != nil {
+			continue
+		}
+		var entry remoteRoutingCacheEntry
+		if json.Unmarshal([]byte(setting.Value), &entry) != nil || entry.Source == "" || entry.Content == "" || entry.FetchedAt <= 0 {
+			continue
+		}
+		if _, remote, err := common.ParseRemoteRoutingURL(entry.Source); err != nil || !remote {
+			continue
+		}
+		normalized, clash, err := normalizeRemoteRoutingContent(kind, []byte(entry.Content))
+		if err != nil {
+			continue
+		}
+		if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
+			continue
+		}
+		entry.Content = normalized
+		entry.Clash = clash
+		loaded[remoteRoutingKey{kind: kind, source: entry.Source}] = entry
+	}
+	r.mu.Lock()
+	for key, entry := range loaded {
+		current, exists := r.entries[key]
+		if !exists || entry.FetchedAt > current.FetchedAt {
+			r.entries[key] = entry
+		}
+	}
+	r.mu.Unlock()
+}
+
+func (r *remoteRoutingResolver) persistEntry(kind remoteRoutingKind, entry remoteRoutingCacheEntry) {
+	db := database.GetDB()
+	if db == nil {
+		return
+	}
+	encoded, err := json.Marshal(entry)
+	if err != nil {
+		return
+	}
+	key := remoteRoutingSettingKey(kind)
+	var setting model.Setting
+	err = db.Where("key = ?", key).First(&setting).Error
+	if database.IsNotFound(err) {
+		err = db.Create(&model.Setting{Key: key, Value: string(encoded)}).Error
+	} else if err == nil {
+		setting.Value = string(encoded)
+		err = db.Save(&setting).Error
+	}
+	if err != nil {
+		logger.Warningf("Could not persist the last valid %s remote routing value", kind)
+	}
+}

+ 750 - 0
internal/sub/remote_routing_test.go

@@ -0,0 +1,750 @@
+package sub
+
+import (
+	"encoding/base64"
+	"errors"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	yaml "github.com/goccy/go-yaml"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
+	var remote map[string]any
+	if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil {
+		return err
+	}
+	return mergeRemoteClashRules(base, remote)
+}
+
+type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error)
+
+func (fn remoteRoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	return fn(req)
+}
+
+func remoteRoutingTestClient(fn remoteRoutingRoundTripper) *http.Client {
+	return &http.Client{Transport: fn}
+}
+
+func remoteRoutingResponse(status int, body string) *http.Response {
+	return &http.Response{
+		StatusCode: status,
+		Header:     make(http.Header),
+		Body:       io.NopCloser(strings.NewReader(body)),
+	}
+}
+
+func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
+	t.Helper()
+	deadline := time.Now().Add(2 * time.Second)
+	for {
+		resolver.mu.Lock()
+		inflight := len(resolver.inflight)
+		resolver.mu.Unlock()
+		if inflight == 0 {
+			return
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("remote routing refresh did not finish")
+		}
+		time.Sleep(time.Millisecond)
+	}
+}
+
+func waitRemoteRoutingLoadIdle(t *testing.T, resolver *remoteRoutingResolver) {
+	t.Helper()
+	deadline := time.Now().Add(2 * time.Second)
+	for {
+		resolver.mu.Lock()
+		loading := resolver.loadInFlight
+		resolver.mu.Unlock()
+		if !loading {
+			return
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("persisted routing cache load did not finish")
+		}
+		time.Sleep(time.Millisecond)
+	}
+}
+
+func primeRemoteRouting(t *testing.T, resolver *remoteRoutingResolver, kind remoteRoutingKind, source string) string {
+	t.Helper()
+	if err := resolver.refreshSource(kind, source); err != nil {
+		t.Fatalf("prime remote routing: %v", err)
+	}
+	value, remote, err := resolver.resolve(kind, source)
+	if err != nil || !remote || value == "" {
+		t.Fatalf("primed resolve got=%q remote=%v err=%v", value, remote, err)
+	}
+	return value
+}
+
+func TestNormalizeHappRoutingAcceptsJSONAndDeeplink(t *testing.T) {
+	deeplink, err := normalizeHappRouting([]byte(`{"Name":"RoscomVPN","GlobalProxy":"true"}`))
+	if err != nil {
+		t.Fatalf("normalize JSON: %v", err)
+	}
+	const prefix = "happ://routing/onadd/"
+	if !strings.HasPrefix(deeplink, prefix) {
+		t.Fatalf("deeplink = %q", deeplink)
+	}
+	decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(deeplink, prefix))
+	if err != nil || !strings.Contains(string(decoded), `"Name":"RoscomVPN"`) {
+		t.Fatalf("decoded payload = %q, err=%v", decoded, err)
+	}
+
+	if got, err := normalizeHappRouting([]byte(deeplink + "\n")); err != nil || got != deeplink {
+		t.Fatalf("ready deeplink got=%q err=%v", got, err)
+	}
+	if _, err := normalizeHappRouting([]byte("routing.help")); err == nil {
+		t.Fatal("invalid Happ response was accepted")
+	}
+}
+
+func TestRemoteRoutingResolverAcceptsHappRedirect(t *testing.T) {
+	deeplink, err := normalizeHappRouting([]byte(`{"Name":"redirected"}`))
+	if err != nil {
+		t.Fatalf("normalize: %v", err)
+	}
+	var requests atomic.Int32
+	client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
+		requests.Add(1)
+		response := remoteRoutingResponse(http.StatusFound, "")
+		response.Header.Set("Location", deeplink)
+		response.Request = req
+		return response, nil
+	})
+	client.CheckRedirect = checkRemoteRoutingRedirect
+	resolver := newRemoteRoutingResolver(client, false)
+
+	const source = "https://routing.example/"
+	if err := resolver.refreshSource(remoteRoutingHapp, source); err != nil {
+		t.Fatalf("refresh redirect: %v", err)
+	}
+	got, remote, err := resolver.resolve(remoteRoutingHapp, source)
+	if err != nil || !remote || got != deeplink {
+		t.Fatalf("redirect resolve got=%q remote=%v err=%v", got, remote, err)
+	}
+	if requests.Load() != 1 {
+		t.Fatalf("network requests = %d, want 1", requests.Load())
+	}
+}
+
+func TestRemoteRoutingResolverHandlesHappNotModified(t *testing.T) {
+	var requests atomic.Int32
+	client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
+		if requests.Add(1) == 1 {
+			response := remoteRoutingResponse(http.StatusOK, `{"Name":"etagged"}`)
+			response.Header.Set("ETag", `"v1"`)
+			return response, nil
+		}
+		if req.Header.Get("If-None-Match") != `"v1"` {
+			t.Errorf("If-None-Match = %q", req.Header.Get("If-None-Match"))
+		}
+		return remoteRoutingResponse(http.StatusNotModified, ""), nil
+	})
+	resolver := newRemoteRoutingResolver(client, false)
+	now := time.Unix(1_800_000_000, 0)
+	resolver.now = func() time.Time { return now }
+	const source = "https://example.com/default.json"
+
+	first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
+	now = now.Add(remoteRoutingCacheTTL + time.Second)
+	second, _, err := resolver.resolve(remoteRoutingHapp, source)
+	if err != nil || second != first {
+		t.Fatalf("stale resolve got=%q err=%v", second, err)
+	}
+	waitRemoteRoutingIdle(t, resolver)
+	now = now.Add(time.Minute)
+	third, _, err := resolver.resolve(remoteRoutingHapp, source)
+	if err != nil || third != first {
+		t.Fatalf("refreshed cache got=%q err=%v", third, err)
+	}
+	if requests.Load() != 2 {
+		t.Fatalf("requests = %d, want 2", requests.Load())
+	}
+}
+
+func TestRemoteRoutingResolverDoesNotBlockAndCoalescesColdFetch(t *testing.T) {
+	var requests atomic.Int32
+	started := make(chan struct{})
+	release := make(chan struct{})
+	var startOnce sync.Once
+	client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		requests.Add(1)
+		startOnce.Do(func() { close(started) })
+		<-release
+		return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
+	})
+	resolver := newRemoteRoutingResolver(client, false)
+	const source = "https://example.com/default.json"
+
+	results := make(chan error, 8)
+	for range 8 {
+		go func() {
+			_, remote, err := resolver.resolve(remoteRoutingHapp, source)
+			if !remote {
+				results <- errors.New("source was not classified as remote")
+				return
+			}
+			results <- err
+		}()
+	}
+	<-started
+	for range 8 {
+		select {
+		case err := <-results:
+			if !errors.Is(err, errRemoteRoutingUnavailable) {
+				t.Fatalf("cold resolve err=%v", err)
+			}
+		case <-time.After(100 * time.Millisecond):
+			t.Fatal("cold resolve blocked on the remote fetch")
+		}
+	}
+	if got := requests.Load(); got != 1 {
+		t.Fatalf("requests = %d, want 1", got)
+	}
+	close(release)
+	waitRemoteRoutingIdle(t, resolver)
+	if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || !strings.HasPrefix(got, "happ://routing/onadd/") {
+		t.Fatalf("cached resolve got=%q err=%v", got, err)
+	}
+	if got := requests.Load(); got != 1 {
+		t.Fatalf("cached request count = %d, want 1", got)
+	}
+}
+
+func TestRemoteRoutingResolverServesStaleAfterFailedRefresh(t *testing.T) {
+	var requests atomic.Int32
+	refreshStarted := make(chan struct{})
+	releaseRefresh := make(chan struct{})
+	var startOnce sync.Once
+	fail := atomic.Bool{}
+	client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		requests.Add(1)
+		if fail.Load() {
+			startOnce.Do(func() { close(refreshStarted) })
+			<-releaseRefresh
+			return remoteRoutingResponse(http.StatusBadGateway, "bad gateway"), nil
+		}
+		return remoteRoutingResponse(http.StatusOK, `{"Name":"last-good"}`), nil
+	})
+	resolver := newRemoteRoutingResolver(client, false)
+	now := time.Unix(1_800_000_000, 0)
+	resolver.now = func() time.Time { return now }
+	const source = "https://example.com/default.json"
+
+	first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
+	fail.Store(true)
+	now = now.Add(remoteRoutingCacheTTL + time.Second)
+	startedAt := time.Now()
+	stale, remote, err := resolver.resolve(remoteRoutingHapp, source)
+	if err != nil || !remote || stale != first {
+		t.Fatalf("stale resolve got=%q remote=%v err=%v", stale, remote, err)
+	}
+	if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
+		t.Fatalf("stale resolve blocked for %v", elapsed)
+	}
+	select {
+	case <-refreshStarted:
+	case <-time.After(time.Second):
+		t.Fatal("refresh did not run")
+	}
+	close(releaseRefresh)
+
+	waitRemoteRoutingIdle(t, resolver)
+
+	if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || got != first {
+		t.Fatalf("negative-cache resolve got=%q err=%v", got, err)
+	}
+	if got := requests.Load(); got != 2 {
+		t.Fatalf("requests = %d, want 2", got)
+	}
+}
+
+func TestRemoteRoutingResolverLoadsPersistedLastGood(t *testing.T) {
+	initSubDB(t)
+
+	deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted"}`))
+	if err != nil {
+		t.Fatalf("normalize: %v", err)
+	}
+	const source = "https://example.com/default.json"
+	entry := remoteRoutingCacheEntry{
+		Source: source, Content: deeplink, FetchedAt: time.Now().Add(-time.Hour).Unix(), ETag: `"v1"`,
+	}
+	newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, entry)
+
+	resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
+	}), true)
+	resolver.ensurePersistedLoaded()
+	got, remote, err := resolver.resolve(remoteRoutingHapp, source)
+	if err != nil || !remote || got != deeplink {
+		t.Fatalf("persisted resolve got=%q remote=%v err=%v", got, remote, err)
+	}
+	waitRemoteRoutingIdle(t, resolver)
+}
+
+func TestRemoteRoutingResolverDoesNotBlockOnPersistedLoad(t *testing.T) {
+	started := make(chan struct{})
+	release := make(chan struct{})
+	var startOnce sync.Once
+	resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		startOnce.Do(func() { close(started) })
+		<-release
+		return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
+	}), true)
+
+	resolver.loadMu.Lock()
+	loadLocked := true
+	t.Cleanup(func() {
+		if loadLocked {
+			resolver.loadMu.Unlock()
+		}
+	})
+
+	startedAt := time.Now()
+	_, remote, err := resolver.resolve(remoteRoutingHapp, "https://example.com/default.json")
+	if !remote || !errors.Is(err, errRemoteRoutingUnavailable) {
+		t.Fatalf("resolve remote=%v err=%v", remote, err)
+	}
+	if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
+		t.Fatalf("resolve blocked on persisted cache load for %v", elapsed)
+	}
+
+	resolver.loadMu.Unlock()
+	loadLocked = false
+	close(release)
+	select {
+	case <-started:
+	case <-time.After(time.Second):
+		t.Fatal("background refresh did not start")
+	}
+	waitRemoteRoutingIdle(t, resolver)
+	waitRemoteRoutingLoadIdle(t, resolver)
+}
+
+func TestRemoteRoutingResolverRejectsOversizedPersistedHappValue(t *testing.T) {
+	initSubDB(t)
+
+	deeplink, err := normalizeHappRouting([]byte(`{"Name":"` + strings.Repeat("x", remoteRoutingHappMaxValue) + `"}`))
+	if err != nil || len(deeplink) <= remoteRoutingHappMaxValue {
+		t.Fatalf("oversized fixture length=%d err=%v", len(deeplink), err)
+	}
+	const source = "https://example.com/oversized.json"
+	newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
+		Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
+	})
+
+	resolver := newRemoteRoutingResolver(nil, true)
+	resolver.ensurePersistedLoaded()
+	resolver.mu.Lock()
+	_, exists := resolver.entries[remoteRoutingKey{kind: remoteRoutingHapp, source: source}]
+	resolver.mu.Unlock()
+	if exists {
+		t.Fatal("oversized persisted Happ routing value was loaded")
+	}
+}
+
+func TestRemoteRoutingResolverDoesNotReplaceClashCacheWithInvalidSchema(t *testing.T) {
+	var requests atomic.Int32
+	client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		if requests.Add(1) == 1 {
+			return remoteRoutingResponse(http.StatusOK, "rules:\n  - MATCH,PROXY\n"), nil
+		}
+		return remoteRoutingResponse(http.StatusOK, "rules: not-a-list\n"), nil
+	})
+	resolver := newRemoteRoutingResolver(client, false)
+	now := time.Unix(1_800_000_000, 0)
+	resolver.now = func() time.Time { return now }
+	const source = "https://example.com/routing.yaml"
+
+	first := primeRemoteRouting(t, resolver, remoteRoutingClash, source)
+	now = now.Add(remoteRoutingCacheTTL + time.Second)
+	second, _, err := resolver.resolve(remoteRoutingClash, source)
+	if err != nil || second != first {
+		t.Fatalf("invalid refresh replaced last-good: got=%q err=%v", second, err)
+	}
+	waitRemoteRoutingIdle(t, resolver)
+	second, _, err = resolver.resolve(remoteRoutingClash, source)
+	if err != nil || second != first {
+		t.Fatalf("invalid refresh replaced last-good after completion: got=%q err=%v", second, err)
+	}
+	if requests.Load() != 2 {
+		t.Fatalf("requests = %d, want 2", requests.Load())
+	}
+}
+
+func TestApplyCommonHeadersResolvesRemoteHappAndFailsClosed(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
+	}), false)
+	const source = "https://example.com/default.json"
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingHapp, source)
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, source, false)
+	if recorder.Header().Get("Routing-Enable") != "true" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
+		t.Fatalf("headers = %#v", recorder.Header())
+	}
+
+	recorder = httptest.NewRecorder()
+	ctx, _ = gin.CreateTestContext(recorder)
+	(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, source, false)
+	if recorder.Header().Get("Routing-Enable") != "" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
+		t.Fatalf("independent routing headers = %#v", recorder.Header())
+	}
+
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(http.StatusOK, "routing.help"), nil
+	}), false)
+	recorder = httptest.NewRecorder()
+	ctx, _ = gin.CreateTestContext(recorder)
+	(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, "https://example.com/bad", false)
+	if recorder.Header().Get("Routing-Enable") != "true" || recorder.Header().Get("Routing") != "" {
+		t.Fatalf("invalid remote source leaked routing headers: %#v", recorder.Header())
+	}
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+}
+
+func TestResolveIncyRemoteSourceUsesAutorouting(t *testing.T) {
+	got, remote, err := resolveIncyRoutingSource("https://example.com/DEFAULT.JSON")
+	if err != nil || !remote || got != "incy://autorouting/onadd/https://example.com/DEFAULT.JSON" {
+		t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
+	}
+	inline := "incy://routing/onadd/abc"
+	if got, remote, err := resolveIncyRoutingSource(inline); err != nil || remote || got != inline {
+		t.Fatalf("inline got=%q remote=%v err=%v", got, remote, err)
+	}
+}
+
+func TestMergeRemoteClashRulesPreservesGeneratedProxies(t *testing.T) {
+	originalProxy := map[string]any{"name": "vpn-node", "type": "vless"}
+	base := map[string]any{
+		"proxies": []map[string]any{originalProxy},
+		"proxy-groups": []map[string]any{{
+			"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
+		}},
+		"rules": []string{"MATCH,PROXY"},
+	}
+	remote := `
+proxies:
+  - name: attacker-controlled
+proxy-providers:
+  prov:
+    url: <SUBSCRIPTION PLACEHOLDER>
+external-controller: 0.0.0.0:9090
+allow-lan: true
+mixed-port: 7890
+dns:
+  enable: true
+tun:
+  enable: true
+proxy-groups:
+  - name: VPN
+    type: select
+    include-all: true
+  - name: PROXY
+    type: select
+    proxies: [VPN]
+rule-providers:
+  roscom:
+    type: http
+    url: https://example.com/rules.mrs
+rules:
+  - RULE-SET,roscom,PROXY
+  - MATCH,PROXY
+`
+	if err := mergeRemoteClashRulesYAML(base, remote); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+	proxies, ok := base["proxies"].([]map[string]any)
+	if !ok || len(proxies) != 1 || proxies[0]["name"] != "vpn-node" {
+		t.Fatalf("generated proxies were replaced: %#v", base["proxies"])
+	}
+	if _, exists := base["proxy-providers"]; exists {
+		t.Fatal("remote proxy-providers were imported")
+	}
+	if _, exists := base["external-controller"]; exists {
+		t.Fatal("unsafe top-level key was imported")
+	}
+	for _, key := range []string{"allow-lan", "mixed-port", "dns", "tun"} {
+		if _, exists := base[key]; exists {
+			t.Fatalf("client-local key %q was imported", key)
+		}
+	}
+	if _, exists := base["rule-providers"]; !exists {
+		t.Fatal("rule-providers were not imported")
+	}
+	groups, ok := asAnySlice(base["proxy-groups"])
+	if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "VPN" || clashProxyGroupName(groups[1]) != "PROXY" {
+		t.Fatalf("proxy groups = %#v", base["proxy-groups"])
+	}
+	rules, ok := asAnySlice(base["rules"])
+	if !ok || len(rules) != 2 || rules[1] != "MATCH,PROXY" {
+		t.Fatalf("rules = %#v", base["rules"])
+	}
+}
+
+func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) {
+	base := map[string]any{
+		"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
+		"proxy-groups": []map[string]any{{
+			"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
+		}},
+		"rules": []string{"MATCH,PROXY"},
+	}
+	if err := mergeRemoteClashRulesYAML(base, `proxy-groups:
+  - name: Extra
+    type: select
+    proxies: [PROXY]
+rules:
+  - MATCH,PROXY
+`); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+	groups, ok := asAnySlice(base["proxy-groups"])
+	if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "Extra" || clashProxyGroupName(groups[1]) != "PROXY" {
+		t.Fatalf("proxy groups = %#v", base["proxy-groups"])
+	}
+}
+
+func TestRemoteRoutingRejectsOversizedHappValues(t *testing.T) {
+	largeJSON := `{"Name":"large","Rules":"` + strings.Repeat("a", remoteRoutingHappMaxValue) + `"}`
+	largeDeeplink, err := normalizeHappRouting([]byte(largeJSON))
+	if err != nil {
+		t.Fatalf("prepare large deeplink: %v", err)
+	}
+
+	tests := []struct {
+		name     string
+		response func(*http.Request) *http.Response
+		wantErr  string
+	}{
+		{
+			name: "response body",
+			response: func(*http.Request) *http.Response {
+				return remoteRoutingResponse(http.StatusOK, strings.Repeat("x", remoteRoutingHappMaxBody+1))
+			},
+			wantErr: "response exceeds the size limit",
+		},
+		{
+			name: "normalized header",
+			response: func(*http.Request) *http.Response {
+				return remoteRoutingResponse(http.StatusOK, largeJSON)
+			},
+			wantErr: "header exceeds the size limit",
+		},
+		{
+			name: "redirect header",
+			response: func(req *http.Request) *http.Response {
+				response := remoteRoutingResponse(http.StatusFound, "")
+				response.Header.Set("Location", largeDeeplink)
+				response.Request = req
+				return response
+			},
+			wantErr: "header exceeds the size limit",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
+				return tt.response(req), nil
+			})
+			client.CheckRedirect = checkRemoteRoutingRedirect
+			resolver := newRemoteRoutingResolver(client, false)
+			err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
+			if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+				t.Fatalf("err=%v, want %q", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestRemoteRoutingRefreshTurnsPanicsIntoErrors(t *testing.T) {
+	client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		panic("transport exploded")
+	})
+	resolver := newRemoteRoutingResolver(client, false)
+	err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
+	if err == nil || !strings.Contains(err.Error(), "panicked") {
+		t.Fatalf("err=%v, want the panic converted into an error", err)
+	}
+	// The inflight slot must be released so later refreshes are not wedged.
+	waitRemoteRoutingIdle(t, resolver)
+}
+
+func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) {
+	resolver := newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), false)
+	startedAt := time.Now()
+	err := resolver.refreshSource(remoteRoutingHapp, "https://127.0.0.1:1/rules")
+	if err == nil {
+		t.Fatal("loopback remote source was accepted")
+	}
+	if elapsed := time.Since(startedAt); elapsed > 2*time.Second {
+		t.Fatalf("loopback rejection took %v", elapsed)
+	}
+}
+
+func TestRemoteRoutingPersistedLoadRetriesAfterDatabaseBecomesReady(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "x-ui.db")
+	if err := database.InitDB(dbPath); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`))
+	if err != nil {
+		t.Fatalf("normalize: %v", err)
+	}
+	const source = "https://example.com/default.json"
+	newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
+		Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
+	})
+	if err := database.CloseDB(); err != nil {
+		t.Fatalf("close db: %v", err)
+	}
+
+	resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
+	}), true)
+	if _, _, err := resolver.resolve(remoteRoutingHapp, source); !errors.Is(err, errRemoteRoutingUnavailable) {
+		t.Fatalf("closed-db resolve err=%v", err)
+	}
+	waitRemoteRoutingIdle(t, resolver)
+	waitRemoteRoutingLoadIdle(t, resolver)
+	if err := database.InitDB(dbPath); err != nil {
+		t.Fatalf("reopen db: %v", err)
+	}
+	resolver.triggerPersistedLoad()
+	waitRemoteRoutingLoadIdle(t, resolver)
+	got, remote, err := resolver.resolve(remoteRoutingHapp, source)
+	if err != nil || !remote || got != deeplink {
+		t.Fatalf("reloaded resolve got=%q remote=%v err=%v", got, remote, err)
+	}
+}
+
+func TestRemoteClashRouteGraphValidation(t *testing.T) {
+	tests := []struct {
+		name    string
+		remote  string
+		wantErr string
+	}{
+		{
+			name:    "missing group name",
+			remote:  "proxy-groups:\n  - type: select\n    proxies: [vpn-node]\nrules:\n  - MATCH,PROXY\n",
+			wantErr: "named group maps",
+		},
+		{
+			name:    "duplicate group name",
+			remote:  "proxy-groups:\n  - {name: A, type: select, proxies: [vpn-node]}\n  - {name: A, type: select, proxies: [vpn-node]}\nrules:\n  - MATCH,A\n",
+			wantErr: "duplicated",
+		},
+		{
+			name:    "unknown group reference",
+			remote:  "proxy-groups:\n  - {name: A, type: select, proxies: [missing]}\nrules:\n  - MATCH,A\n",
+			wantErr: "unknown proxy or group",
+		},
+		{
+			name:    "remote proxy provider use",
+			remote:  "proxy-groups:\n  - name: A\n    type: select\n    use: [manual-provider]\nrules:\n  - MATCH,A\n",
+			wantErr: "cannot use proxy-providers",
+		},
+		{
+			name:    "unknown rule provider",
+			remote:  "proxy-groups:\n  - {name: A, type: select, proxies: [vpn-node]}\nrules:\n  - RULE-SET,missing,A\n  - MATCH,A\n",
+			wantErr: "unknown rule-provider",
+		},
+		{
+			name:    "unknown rule target",
+			remote:  "proxy-groups:\n  - {name: A, type: select, proxies: [vpn-node]}\nrules:\n  - MATCH,missing\n",
+			wantErr: "unknown proxy or group",
+		},
+		{
+			name:    "unknown provider download proxy",
+			remote:  "proxy-groups:\n  - {name: A, type: select, proxies: [vpn-node]}\nrule-providers:\n  p: {type: http, url: https://example.com/p.mrs, proxy: missing}\nrules:\n  - RULE-SET,p,A\n  - MATCH,A\n",
+			wantErr: "rule-provider \"p\" references unknown",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			base := map[string]any{
+				"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
+				"proxy-groups": []map[string]any{{
+					"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
+				}},
+				"rules": []string{"MATCH,PROXY"},
+			}
+			err := mergeRemoteClashRulesYAML(base, tt.remote)
+			if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+				t.Fatalf("err=%v, want %q", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestRemoteClashRouteGraphAcceptsLogicalRulesAndCachedDocument(t *testing.T) {
+	const remote = `
+proxy-groups:
+  - name: Auto
+    type: url-test
+    include-all: true
+  - name: Video
+    type: select
+    proxies: [Auto, DIRECT]
+rule-providers:
+  video:
+    type: http
+    url: https://example.com/video.mrs
+    proxy: Auto
+rules:
+  - RULE-SET,video,Video
+  - AND,((NETWORK,TCP),(DST-PORT,443)),Video
+  - GEOIP,private,DIRECT,no-resolve
+  - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve,src
+  - MATCH,Auto
+`
+	var requests atomic.Int32
+	resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		requests.Add(1)
+		return remoteRoutingResponse(http.StatusOK, remote), nil
+	}), false)
+	const source = "https://example.com/routing.yaml"
+	if err := resolver.refreshSource(remoteRoutingClash, source); err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+	entry, remoteSource, err := resolver.resolveEntry(remoteRoutingClash, source)
+	if err != nil || !remoteSource || entry.Clash == nil {
+		t.Fatalf("entry remote=%v parsed=%v err=%v", remoteSource, entry.Clash != nil, err)
+	}
+	base := map[string]any{
+		"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
+		"proxy-groups": []map[string]any{{
+			"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
+		}},
+		"rules": []string{"MATCH,PROXY"},
+	}
+	if err := mergeRemoteClashRules(base, entry.Clash); err != nil {
+		t.Fatalf("merge cached document: %v", err)
+	}
+	if requests.Load() != 1 {
+		t.Fatalf("requests=%d, want 1", requests.Load())
+	}
+}

+ 27 - 1
internal/util/common/url.go

@@ -1,6 +1,10 @@
 package common
 
-import "strings"
+import (
+	"errors"
+	"net/url"
+	"strings"
+)
 
 // EnsureURLScheme prepends https:// to a URL that carries no scheme, so
 // subscription apps and browsers don't resolve it relative to the panel's own
@@ -19,3 +23,25 @@ func EnsureURLScheme(raw string) string {
 	}
 	return "https://" + trimmed
 }
+
+// ParseRemoteRoutingURL classifies a routing settings value: one single-line
+// absolute HTTPS URL is a remote source (canonicalized); anything else is inline.
+func ParseRemoteRoutingURL(raw string) (string, bool, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" || strings.ContainsAny(trimmed, "\r\n") {
+		return "", false, nil
+	}
+	if !strings.HasPrefix(strings.ToLower(trimmed), "https://") {
+		return "", false, nil
+	}
+	u, err := url.Parse(trimmed)
+	if err != nil || u.Host == "" || u.Hostname() == "" {
+		return "", true, errors.New("must be an absolute HTTPS URL")
+	}
+	if u.User != nil {
+		return "", true, errors.New("must not contain URL credentials")
+	}
+	u.Scheme = "https"
+	u.Fragment = ""
+	return u.String(), true, nil
+}

+ 26 - 0
internal/util/common/url_test.go

@@ -27,3 +27,29 @@ func TestEnsureURLScheme(t *testing.T) {
 		})
 	}
 }
+
+func TestParseRemoteRoutingURLKeepsInlineCompatibility(t *testing.T) {
+	tests := []struct {
+		name       string
+		input      string
+		wantSource string
+		wantRemote bool
+		wantErr    bool
+	}{
+		{name: "deeplink stays inline", input: "happ://routing/onadd/abc"},
+		{name: "plain HTTP stays inline", input: "http://example.com/rules"},
+		{name: "multiline stays inline", input: "https://example.com/rules\nMATCH,PROXY"},
+		{name: "HTTPS source", input: "  https://example.com/rules#ignored  ", wantSource: "https://example.com/rules", wantRemote: true},
+		{name: "uppercase scheme", input: "HTTPS://example.com/rules", wantSource: "https://example.com/rules", wantRemote: true},
+		{name: "credentials rejected", input: "https://user:[email protected]/rules", wantRemote: true, wantErr: true},
+		{name: "missing host rejected", input: "https:///rules", wantRemote: true, wantErr: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, remote, err := ParseRemoteRoutingURL(tt.input)
+			if got != tt.wantSource || remote != tt.wantRemote || (err != nil) != tt.wantErr {
+				t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
+			}
+		})
+	}
+}

+ 16 - 2
internal/util/netsafe/netsafe.go

@@ -2,6 +2,7 @@ package netsafe
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net"
 	"regexp"
@@ -9,6 +10,11 @@ import (
 	"time"
 )
 
+// ErrPrivateAddressBlocked marks a failed dial where the guard refused at least
+// one resolved address, so a caller offering an opt-in can tell it apart from an
+// ordinary connection failure.
+var ErrPrivateAddressBlocked = errors.New("blocked private/internal address")
+
 func IsBlockedIP(ip net.IP) bool {
 	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
 		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
@@ -42,10 +48,10 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn
 			return nil, err
 		}
 	}
-	var lastErr error
+	var lastErr, blockedErr error
 	for _, ipAddr := range ips {
 		if !allowPrivate && IsBlockedIP(ipAddr.IP) {
-			lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
+			blockedErr = fmt.Errorf("%w %s", ErrPrivateAddressBlocked, ipAddr.IP)
 			continue
 		}
 		conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
@@ -54,6 +60,14 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn
 		}
 		lastErr = derr
 	}
+	// A dual-stack name can mix refused and merely unreachable addresses, so the
+	// refusal is reported alongside instead of being lost to the last failure.
+	if blockedErr != nil {
+		if lastErr != nil {
+			return nil, fmt.Errorf("%w; %w", blockedErr, lastErr)
+		}
+		return nil, blockedErr
+	}
 	if lastErr == nil {
 		lastErr = fmt.Errorf("no usable address for %s", host)
 	}

+ 1 - 0
internal/web/cadence_test.go

@@ -23,6 +23,7 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) {
 		"cadenceNodeHeartbeat": cadenceNodeHeartbeat,
 		"cadenceNodeTraffic":   cadenceNodeTraffic,
 		"cadenceOutboundSub":   cadenceOutboundSub,
+		"cadenceRemoteRouting": cadenceRemoteRouting,
 		"cadenceCheckHash":     cadenceCheckHash,
 		"cadenceCPUAlarm":      cadenceCPUAlarm,
 	}

+ 23 - 0
internal/web/controller/dist.go

@@ -71,6 +71,28 @@ func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
 	return json.Marshal(doc)
 }
 
+func normalizeWebBasePath(basePath string) string {
+	if basePath == "" {
+		return "/"
+	}
+	if !strings.HasPrefix(basePath, "/") {
+		basePath = "/" + basePath
+	}
+	if !strings.HasSuffix(basePath, "/") {
+		basePath += "/"
+	}
+	return basePath
+}
+
+func pwaHeadInjection(basePath, pageName string) []byte {
+	if pageName != "index.html" && pageName != "login.html" {
+		return nil
+	}
+
+	basePath = normalizeWebBasePath(basePath)
+	return []byte(`<link rel="manifest" href="` + htmlpkg.EscapeString(basePath+"manifest.webmanifest") + `"><script data-cfasync="false" defer src="` + htmlpkg.EscapeString(basePath+"pwa-register.js") + `"></script>`)
+}
+
 func serveDistPage(c *gin.Context, name string) {
 	body, err := fs.ReadFile(distFS, "dist/"+name)
 	if err != nil {
@@ -120,6 +142,7 @@ func serveDistPage(c *gin.Context, name string) {
 	inject := []byte(script)
 	inject = append(inject, csrfMeta...)
 	inject = append(inject, basePathMeta...)
+	inject = append(inject, pwaHeadInjection(basePath, name)...)
 	inject = append(inject, []byte(`</head>`)...)
 	out := bytes.Replace(body, []byte("</head>"), inject, 1)
 

+ 31 - 0
internal/web/controller/dist_test.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"encoding/json"
+	"strings"
 	"testing"
 )
 
@@ -40,3 +41,33 @@ func TestWithServerBasePathInvalidJSON(t *testing.T) {
 		t.Errorf("expected error on invalid spec, got nil")
 	}
 }
+
+func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) {
+	tests := []struct {
+		name     string
+		basePath string
+		wantPath string
+	}{
+		{name: "root", basePath: "/", wantPath: "/manifest.webmanifest"},
+		{name: "secret path", basePath: "panel-secret", wantPath: "/panel-secret/manifest.webmanifest"},
+		{name: "trailing slash", basePath: "/panel-secret/", wantPath: "/panel-secret/manifest.webmanifest"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			head := string(pwaHeadInjection(test.basePath, "login.html"))
+			if !strings.Contains(head, `href="`+test.wantPath+`"`) {
+				t.Fatalf("manifest URL = %q, want %q", head, test.wantPath)
+			}
+			if !strings.Contains(head, `src="`+strings.Replace(test.wantPath, "manifest.webmanifest", "pwa-register.js", 1)+`"`) {
+				t.Fatalf("registration URL = %q", head)
+			}
+		})
+	}
+}
+
+func TestPWAHeadInjectionSkipsSubscriptionPage(t *testing.T) {
+	if got := pwaHeadInjection("/panel-secret/", "subpage.html"); got != nil {
+		t.Fatalf("subpage injection = %q, want nil", got)
+	}
+}

+ 60 - 0
internal/web/controller/pwa.go

@@ -0,0 +1,60 @@
+package controller
+
+import (
+	"io/fs"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+type pwaAsset struct {
+	path        string
+	contentType string
+}
+
+var pwaAssets = map[string]pwaAsset{
+	"manifest.webmanifest": {path: "dist/manifest.webmanifest", contentType: "application/manifest+json; charset=utf-8"},
+	"pwa-register.js":      {path: "dist/pwa-register.js", contentType: "application/javascript; charset=utf-8"},
+	"service-worker.js":    {path: "dist/service-worker.js", contentType: "application/javascript; charset=utf-8"},
+	"icons/3x-ui-16.png":   {path: "dist/icons/3x-ui-16.png", contentType: "image/png"},
+	"icons/3x-ui-24.png":   {path: "dist/icons/3x-ui-24.png", contentType: "image/png"},
+	"icons/3x-ui-32.png":   {path: "dist/icons/3x-ui-32.png", contentType: "image/png"},
+	"icons/3x-ui-64.png":   {path: "dist/icons/3x-ui-64.png", contentType: "image/png"},
+	"icons/3x-ui-192.png":  {path: "dist/icons/3x-ui-192.png", contentType: "image/png"},
+	"icons/3x-ui-512.png":  {path: "dist/icons/3x-ui-512.png", contentType: "image/png"},
+}
+
+func servePWAAsset(c *gin.Context, assetName string) {
+	asset, ok := pwaAssets[assetName]
+	if !ok {
+		c.AbortWithStatus(http.StatusNotFound)
+		return
+	}
+
+	body, err := fs.ReadFile(distFS, asset.path)
+	if err != nil {
+		c.AbortWithStatus(http.StatusNotFound)
+		return
+	}
+
+	c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
+	c.Header("Pragma", "no-cache")
+	c.Header("Expires", "0")
+	c.Data(http.StatusOK, asset.contentType, body)
+}
+
+func ServePWAManifest(c *gin.Context) {
+	servePWAAsset(c, "manifest.webmanifest")
+}
+
+func ServePWARegister(c *gin.Context) {
+	servePWAAsset(c, "pwa-register.js")
+}
+
+func ServePWAServiceWorker(c *gin.Context) {
+	servePWAAsset(c, "service-worker.js")
+}
+
+func ServePWAIcon(c *gin.Context) {
+	servePWAAsset(c, "icons/"+c.Param("name"))
+}

+ 95 - 0
internal/web/controller/pwa_test.go

@@ -0,0 +1,95 @@
+package controller
+
+import (
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"testing/fstest"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestServePWAAssets(t *testing.T) {
+	oldDistFS := distFS
+	distFS = fstest.MapFS{
+		"dist/manifest.webmanifest": &fstest.MapFile{Data: []byte(`{"name":"3x-ui"}`)},
+		"dist/pwa-register.js":      &fstest.MapFile{Data: []byte("register")},
+		"dist/service-worker.js":    &fstest.MapFile{Data: []byte("worker")},
+		"dist/icons/3x-ui-192.png":  &fstest.MapFile{Data: []byte("icon-192")},
+		"dist/icons/3x-ui-512.png":  &fstest.MapFile{Data: []byte("icon-512")},
+	}
+	t.Cleanup(func() { distFS = oldDistFS })
+
+	tests := []struct {
+		name        string
+		handler     gin.HandlerFunc
+		contentType string
+		body        string
+	}{
+		{name: "manifest", handler: ServePWAManifest, contentType: "application/manifest+json; charset=utf-8", body: `{"name":"3x-ui"}`},
+		{name: "registration", handler: ServePWARegister, contentType: "application/javascript; charset=utf-8", body: "register"},
+		{name: "worker", handler: ServePWAServiceWorker, contentType: "application/javascript; charset=utf-8", body: "worker"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			gin.SetMode(gin.TestMode)
+			response := httptest.NewRecorder()
+			context, _ := gin.CreateTestContext(response)
+			test.handler(context)
+
+			if response.Code != 200 {
+				t.Fatalf("status = %d, want 200", response.Code)
+			}
+			if response.Header().Get("Content-Type") != test.contentType {
+				t.Errorf("content type = %q, want %q", response.Header().Get("Content-Type"), test.contentType)
+			}
+			if response.Header().Get("Cache-Control") != "no-cache, no-store, must-revalidate" {
+				t.Errorf("cache control = %q", response.Header().Get("Cache-Control"))
+			}
+			if strings.TrimSpace(response.Body.String()) != test.body {
+				t.Errorf("body = %q, want %q", response.Body.String(), test.body)
+			}
+		})
+	}
+}
+
+func TestServePWAIconServesPNG(t *testing.T) {
+	oldDistFS := distFS
+	distFS = fstest.MapFS{
+		"dist/icons/3x-ui-192.png": &fstest.MapFile{Data: []byte("icon-192")},
+	}
+	t.Cleanup(func() { distFS = oldDistFS })
+
+	gin.SetMode(gin.TestMode)
+	response := httptest.NewRecorder()
+	context, _ := gin.CreateTestContext(response)
+	context.Params = gin.Params{{Key: "name", Value: "3x-ui-192.png"}}
+	ServePWAIcon(context)
+
+	if response.Code != 200 {
+		t.Fatalf("status = %d, want 200", response.Code)
+	}
+	if got := response.Header().Get("Content-Type"); got != "image/png" {
+		t.Errorf("content type = %q, want %q", got, "image/png")
+	}
+	if response.Body.String() != "icon-192" {
+		t.Errorf("body = %q, want %q", response.Body.String(), "icon-192")
+	}
+}
+
+func TestServePWAIconRejectsUnknownName(t *testing.T) {
+	oldDistFS := distFS
+	distFS = fstest.MapFS{}
+	t.Cleanup(func() { distFS = oldDistFS })
+
+	gin.SetMode(gin.TestMode)
+	response := httptest.NewRecorder()
+	context, _ := gin.CreateTestContext(response)
+	context.Params = gin.Params{{Key: "name", Value: "../../etc/passwd"}}
+	ServePWAIcon(context)
+
+	if response.Code != 404 {
+		t.Fatalf("status = %d, want 404", response.Code)
+	}
+}

+ 4 - 3
internal/web/controller/server.go

@@ -464,11 +464,12 @@ func (a *ServerController) getRemoteCertHash(c *gin.Context) {
 	jsonObj(c, hashes, nil)
 }
 
-// scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY
-// target and returns a structured feasibility verdict plus the cert SAN names.
+// scanRealityTarget probes the candidate REALITY target with the given sni and
+// returns a feasibility verdict; allowPrivate is the panel's confirmed opt-in.
 func (a *ServerController) scanRealityTarget(c *gin.Context) {
 	xver, _ := strconv.Atoi(c.PostForm("xver"))
-	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), xver)
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), c.PostForm("sni"), xver, allowPrivate)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
 		return

+ 0 - 0
internal/web/dist/.gitkeep


+ 40 - 1
internal/web/entity/check_valid_test.go

@@ -1,6 +1,9 @@
 package entity
 
-import "testing"
+import (
+	"strings"
+	"testing"
+)
 
 func TestCheckValidSmtpFrom(t *testing.T) {
 	base := func() *AllSetting {
@@ -39,3 +42,39 @@ func TestCheckValidWildcardListenPortConflict(t *testing.T) {
 		t.Errorf("distinct specific listens on the same port should be allowed: %v", err)
 	}
 }
+
+// The allowlist and the trusted-proxy list share one validator, so this also
+// pins that each list still reports its own message (#5378).
+func TestCheckValidIPOrCIDRLists(t *testing.T) {
+	base := func() *AllSetting {
+		return &AllSetting{WebPort: 2053, SubPort: 2096}
+	}
+
+	for _, v := range []string{"", "203.0.113.10", "198.51.100.0/24", " 203.0.113.10 , 2001:db8::/32 ", "203.0.113.10,,"} {
+		s := base()
+		s.IpLimitAllowlist = v
+		if err := s.CheckValid(); err != nil {
+			t.Errorf("ipLimitAllowlist=%q: unexpected error %v", v, err)
+		}
+	}
+
+	for _, v := range []string{"nonsense", "203.0.113.10/33", "203.0.113.10, oops"} {
+		s := base()
+		s.IpLimitAllowlist = v
+		err := s.CheckValid()
+		if err == nil {
+			t.Errorf("ipLimitAllowlist=%q: want error, got nil", v)
+			continue
+		}
+		if !strings.Contains(err.Error(), "IP limit allowlist entry is not valid:") {
+			t.Errorf("ipLimitAllowlist=%q: error %q does not name the setting", v, err)
+		}
+	}
+
+	s := base()
+	s.TrustedProxyCIDRs = "127.0.0.1/32, bogus"
+	err := s.CheckValid()
+	if err == nil || !strings.Contains(err.Error(), "trusted proxy CIDR is not valid: bogus") {
+		t.Errorf("trustedProxyCIDRs error = %v, want it to name the trusted-proxy list and the bad entry", err)
+	}
+}

+ 46 - 11
internal/web/entity/entity.go

@@ -5,6 +5,7 @@ import (
 	"math"
 	"net"
 	"net/mail"
+	"net/netip"
 	"strings"
 	"time"
 
@@ -26,6 +27,7 @@ type AllSetting struct {
 	WebBasePath       string `json:"webBasePath" form:"webBasePath"`
 	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
+	IpLimitAllowlist  string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 
 	PageSize                  int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
@@ -152,6 +154,42 @@ func pathHasForbiddenChar(s string) bool {
 	return false
 }
 
+// CheckNetipAddrOrPrefixList mirrors parseIpLimitAllowlist exactly: net and netip
+// disagree (net accepts "/024", netip does not), so save and scan must share rules.
+func CheckNetipAddrOrPrefixList(list, message string) error {
+	for entry := range strings.SplitSeq(list, ",") {
+		entry = strings.TrimSpace(entry)
+		if entry == "" {
+			continue
+		}
+		if _, err := netip.ParseAddr(entry); err == nil {
+			continue
+		}
+		if _, err := netip.ParsePrefix(entry); err != nil {
+			return common.NewError(message, entry)
+		}
+	}
+	return nil
+}
+
+// checkIPOrCIDRList rejects the first comma-separated entry that is neither a
+// bare address nor a CIDR, naming it with the caller's message.
+func checkIPOrCIDRList(list, message string) error {
+	for entry := range strings.SplitSeq(list, ",") {
+		entry = strings.TrimSpace(entry)
+		if entry == "" {
+			continue
+		}
+		if ip := net.ParseIP(entry); ip != nil {
+			continue
+		}
+		if _, _, err := net.ParseCIDR(entry); err != nil {
+			return common.NewError(message, entry)
+		}
+	}
+	return nil
+}
+
 func (s *AllSetting) CheckValid() error {
 	if s.WebListen != "" {
 		ip := net.ParseIP(s.WebListen)
@@ -234,17 +272,14 @@ func (s *AllSetting) CheckValid() error {
 		s.SubClashPath += "/"
 	}
 
-	for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
-		cidr = strings.TrimSpace(cidr)
-		if cidr == "" {
-			continue
-		}
-		if ip := net.ParseIP(cidr); ip != nil {
-			continue
-		}
-		if _, _, err := net.ParseCIDR(cidr); err != nil {
-			return common.NewError("trusted proxy CIDR is not valid:", cidr)
-		}
+	if err := checkIPOrCIDRList(s.TrustedProxyCIDRs, "trusted proxy CIDR is not valid:"); err != nil {
+		return err
+	}
+
+	// Rejected here rather than skipped at scan time: a typo in an allowlist
+	// entry silently leaves the address unprotected until a trusted network gets banned.
+	if err := CheckNetipAddrOrPrefixList(s.IpLimitAllowlist, "IP limit allowlist entry is not valid:"); err != nil {
+		return err
 	}
 
 	_, err := time.LoadLocation(s.TimeLocation)

+ 25 - 2
internal/web/job/check_client_ip_job.go

@@ -35,6 +35,7 @@ type CheckClientIpJob struct {
 	disAllowedIps []string
 	bannedSeen    map[string]int64
 	xrayService   service.XrayService
+	allowlist     ipLimitAllowlist
 }
 
 var job *CheckClientIpJob
@@ -67,7 +68,13 @@ func (j *CheckClientIpJob) Run() {
 	if hasLimit {
 		f2bInstalled = j.checkFail2BanInstalled()
 	}
-	j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
+	// Read only when the limit is actually applied: this runs every 10s and
+	// most panels carry no IP limit at all.
+	enforce := j.resolveEnforce(hasLimit, f2bInstalled)
+	if enforce {
+		j.allowlist = j.loadAllowlist()
+	}
+	j.processObserved(observed, enforce, true)
 }
 
 // resolveEnforce decides whether limits can actually be enforced this run.
@@ -126,6 +133,18 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 	return err == nil && probe > 0
 }
 
+// loadAllowlist reads the operator's trusted addresses once per scan; a bad
+// read leaves the list empty, which enforces the limit as before rather than
+// silently exempting everyone.
+func (j *CheckClientIpJob) loadAllowlist() ipLimitAllowlist {
+	raw, err := (&service.SettingService{}).GetIpLimitAllowlist()
+	if err != nil {
+		logger.Warning("[LimitIP] could not read the allowlist, enforcing without it:", err)
+		return ipLimitAllowlist{}
+	}
+	return parseIpLimitAllowlist(raw)
+}
+
 const ipScanChunk = 400
 
 func chunkEmails(s []string, size int) [][]string {
@@ -510,7 +529,11 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
 	j.disAllowedIps = []string{}
 
 	// historical db-only ips are excluded from this count on purpose.
-	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
+	limitedIps, allowedIps := j.allowlist.split(liveIps)
+	keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
+	// Allowlisted addresses stay connected and out of the count: charging them
+	// against the limit would still cut the shared network the entry protects.
+	keptLive = append(keptLive, allowedIps...)
 	actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
 	if len(actionable) > 0 {
 		shouldCleanLog = true

+ 49 - 0
internal/web/job/check_client_ip_job_integration_test.go

@@ -419,3 +419,52 @@ func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
 		t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
 	}
 }
+
+// The mirror of TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned: with the
+// older address on the operator's allowlist nothing may be banned, it must not
+// consume the limit, and no fail2ban line may be written for it (#5378).
+func TestUpdateInboundClientIps_AllowlistedIpIsNeitherCountedNorBanned(t *testing.T) {
+	setupIntegrationDB(t)
+
+	const email = "issue5378-office"
+	seedInboundWithClient(t, "inbound-issue5378", email, 1)
+
+	now := time.Now().Unix()
+	row := seedClientIps(t, email, []IPWithTimestamp{
+		{IP: "203.0.113.10", Timestamp: now - 60},
+	})
+
+	j := NewCheckClientIpJob()
+	j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
+
+	live := []IPWithTimestamp{
+		{IP: "203.0.113.10", Timestamp: now - 5},
+		{IP: "192.0.2.9", Timestamp: now},
+	}
+
+	inbound, err := j.getInboundByEmail(email)
+	if err != nil {
+		t.Fatalf("getInboundByEmail: %v", err)
+	}
+	_, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
+
+	if banned {
+		t.Fatal("an allowlisted address pushed the client over its limit and something was banned")
+	}
+	if len(j.disAllowedIps) != 0 {
+		t.Fatalf("disAllowedIps = %v, want none", j.disAllowedIps)
+	}
+
+	persisted := ipSet(readClientIps(t, email))
+	for _, ip := range []string{"203.0.113.10", "192.0.2.9"} {
+		if _, ok := persisted[ip]; !ok {
+			t.Errorf("%s must still be persisted; got %v", ip, persisted)
+		}
+	}
+
+	if body, err := os.ReadFile(readIpLimitLogPath()); err == nil {
+		if contains(string(body), "203.0.113.10") {
+			t.Fatalf("an allowlisted address reached the fail2ban log:\n%s", body)
+		}
+	}
+}

+ 83 - 0
internal/web/job/ip_limit_allowlist.go

@@ -0,0 +1,83 @@
+package job
+
+import (
+	"net/netip"
+	"strings"
+)
+
+// An address that matches is neither counted towards a client's IP limit nor
+// banned: counting it would still cut the shared network it protects (#5378).
+type ipLimitAllowlist struct {
+	prefixes []netip.Prefix
+	addrs    []netip.Addr
+}
+
+// Comma-separated, each entry a CIDR or a bare address. Unparseable entries are
+// skipped: the validator uses these same rules, so only a hand-edited DB differs.
+func parseIpLimitAllowlist(raw string) ipLimitAllowlist {
+	var list ipLimitAllowlist
+	for _, field := range strings.Split(raw, ",") {
+		field = strings.TrimSpace(field)
+		if field == "" {
+			continue
+		}
+		if prefix, err := netip.ParsePrefix(field); err == nil {
+			// Unmapped: contains() unmaps the queried address, and Prefix.Contains
+			// is false whenever the bit lengths disagree.
+			if addr := prefix.Addr(); addr.Is4In6() {
+				if p4, perr := addr.Unmap().Prefix(prefix.Bits() - 96); perr == nil {
+					prefix = p4
+				}
+			}
+			list.prefixes = append(list.prefixes, prefix.Masked())
+			continue
+		}
+		if addr, err := netip.ParseAddr(field); err == nil {
+			list.addrs = append(list.addrs, addr.Unmap())
+		}
+	}
+	return list
+}
+
+func (l ipLimitAllowlist) empty() bool {
+	return len(l.prefixes) == 0 && len(l.addrs) == 0
+}
+
+func (l ipLimitAllowlist) contains(ip string) bool {
+	if l.empty() {
+		return false
+	}
+	addr, err := netip.ParseAddr(strings.TrimSpace(ip))
+	if err != nil {
+		return false
+	}
+	addr = addr.Unmap()
+	for _, allowed := range l.addrs {
+		if allowed == addr {
+			return true
+		}
+	}
+	for _, prefix := range l.prefixes {
+		if prefix.Contains(addr) {
+			return true
+		}
+	}
+	return false
+}
+
+// split separates the entries an allowlist protects from the ones the limit
+// still applies to, preserving the caller's ordering in both.
+func (l ipLimitAllowlist) split(entries []IPWithTimestamp) (limited, allowed []IPWithTimestamp) {
+	if l.empty() {
+		return entries, nil
+	}
+	limited = make([]IPWithTimestamp, 0, len(entries))
+	for _, entry := range entries {
+		if l.contains(entry.IP) {
+			allowed = append(allowed, entry)
+			continue
+		}
+		limited = append(limited, entry)
+	}
+	return limited, allowed
+}

+ 36 - 0
internal/web/job/ip_limit_allowlist_agreement_test.go

@@ -0,0 +1,36 @@
+package job
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+)
+
+// Save-time validation and scan-time parsing must accept exactly the same set:
+// anything the validator lets through and the parser drops is silently unprotected.
+func TestAllowlistValidatorAndParserAgree(t *testing.T) {
+	for _, entry := range []string{
+		"198.51.100.7",
+		"198.51.100.0/24",
+		"2001:db8::1",
+		"2001:db8::/32",
+		"198.51.100.0/024",
+		"not-an-address",
+		"198.51.100.0/33",
+	} {
+		accepted := entity.CheckNetipAddrOrPrefixList(entry, "invalid:") == nil
+		parsed := len(parseIpLimitAllowlist(entry).prefixes)+len(parseIpLimitAllowlist(entry).addrs) > 0
+		if accepted != parsed {
+			t.Errorf("%q: validator=%v parser=%v — a disagreement leaves the entry silently unprotected", entry, accepted, parsed)
+		}
+	}
+}
+
+// An IPv4-mapped prefix used to parse but never match, because contains() unmaps
+// the queried address and Prefix.Contains is false across bit lengths.
+func TestAllowlistMatchesIPv4MappedPrefix(t *testing.T) {
+	list := parseIpLimitAllowlist("::ffff:198.51.100.0/120")
+	if !list.contains("198.51.100.5") {
+		t.Fatal("an IPv4-mapped entry matched nothing: it protects no one")
+	}
+}

+ 70 - 0
internal/web/job/ip_limit_allowlist_test.go

@@ -0,0 +1,70 @@
+package job
+
+import "testing"
+
+// Addresses in the examples below come from the documentation ranges reserved
+// by RFC 5737 and RFC 3849.
+func TestIpLimitAllowlistMatchesAddressesAndNetworks(t *testing.T) {
+	list := parseIpLimitAllowlist("203.0.113.10, 198.51.100.0/24 , 2001:db8::/32, not-an-ip")
+
+	for _, ip := range []string{"203.0.113.10", "198.51.100.7", "2001:db8::1"} {
+		if !list.contains(ip) {
+			t.Fatalf("%s should be allowlisted", ip)
+		}
+	}
+	for _, ip := range []string{"203.0.113.11", "192.0.2.5", "2001:db9::1", ""} {
+		if list.contains(ip) {
+			t.Fatalf("%s must not be allowlisted", ip)
+		}
+	}
+}
+
+// A typo must not disable the limit for everybody, so an unparsable entry is
+// dropped and the rest of the list keeps working.
+func TestIpLimitAllowlistIgnoresUnparsableEntries(t *testing.T) {
+	list := parseIpLimitAllowlist("nonsense, 203.0.113.0/24")
+	if !list.contains("203.0.113.5") {
+		t.Fatal("a valid entry stopped working because a neighbouring one was malformed")
+	}
+	if list.contains("192.0.2.1") {
+		t.Fatal("a malformed entry must not widen the allowlist")
+	}
+	if parseIpLimitAllowlist("nonsense").empty() != true {
+		t.Fatal("a list of only malformed entries must be empty, not permissive")
+	}
+}
+
+// The point of the setting: a shared address is neither banned nor counted, so
+// the office NAT it protects does not consume the client's limit either.
+func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
+	live := []IPWithTimestamp{
+		{IP: "203.0.113.10", Timestamp: 1},
+		{IP: "192.0.2.1", Timestamp: 2},
+		{IP: "192.0.2.2", Timestamp: 3},
+	}
+	list := parseIpLimitAllowlist("203.0.113.10")
+
+	limited, allowed := list.split(live)
+	if len(allowed) != 1 || allowed[0].IP != "203.0.113.10" {
+		t.Fatalf("allowed = %v, want the allowlisted address alone", allowed)
+	}
+	if len(limited) != 2 {
+		t.Fatalf("limited = %v, want the two ordinary addresses", limited)
+	}
+
+	kept, banned := selectIpsToBan(limited, 2)
+	if len(banned) != 0 {
+		t.Fatalf("banned = %v, want none: the allowlisted address must not push an ordinary one over the limit", banned)
+	}
+	if len(kept) != 2 {
+		t.Fatalf("kept = %v, want both ordinary addresses", kept)
+	}
+}
+
+func TestIpLimitAllowlistEmptyListChangesNothing(t *testing.T) {
+	live := []IPWithTimestamp{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
+	limited, allowed := parseIpLimitAllowlist("").split(live)
+	if allowed != nil || len(limited) != 2 {
+		t.Fatalf("empty allowlist changed the input: limited=%v allowed=%v", limited, allowed)
+	}
+}

+ 206 - 0
internal/web/job/periodic_traffic_reset_client_test.go

@@ -0,0 +1,206 @@
+package job
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func initResetJobDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+type seededClient struct {
+	email        string
+	cycle        string
+	day          int
+	recordEnable bool
+	quotaEnable  bool
+	total        int64
+}
+
+// seedClientOnCycle creates an inbound that never resets on its own, a client
+// carrying its own cycle, and the client_inbounds link the reset path resolves
+// through — without it every reset falls into the orphaned-client branch.
+func seedClientOnCycle(t *testing.T, port int, c seededClient) {
+	t.Helper()
+	db := database.GetDB()
+
+	client := model.Client{
+		Email: c.email, ID: uuidFor(port), Enable: c.recordEnable,
+		TrafficReset: c.cycle, TrafficResetDay: c.day,
+	}
+	settings, err := json.Marshal(map[string]any{"clients": []model.Client{client}})
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+	ib := model.Inbound{
+		UserId: 1, Enable: true, Port: port, Protocol: model.VLESS,
+		Tag: "inbound-" + c.email, TrafficReset: "never", Settings: string(settings),
+	}
+	if err := db.Create(&ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := model.ClientRecord{
+		Email: c.email, UUID: client.ID, Enable: c.recordEnable,
+		TrafficReset: c.cycle, TrafficResetDay: c.day,
+	}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client record: %v", err)
+	}
+	// gorm skips a false bool on insert, so the column default:true wins; the
+	// disabled case has to be written back explicitly.
+	if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
+		Update("enable", c.recordEnable).Error; err != nil {
+		t.Fatalf("set record enable: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("link client to inbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: c.email, Enable: c.quotaEnable, Up: 500, Down: 700, Total: c.total,
+	}).Error; err != nil {
+		t.Fatalf("create traffic: %v", err)
+	}
+}
+
+func uuidFor(port int) string {
+	return "00000000-0000-0000-0000-0000000" + string(rune('0'+port/10000%10)) +
+		string(rune('0'+port/1000%10)) + string(rune('0'+port/100%10)) +
+		string(rune('0'+port/10%10)) + string(rune('0'+port%10))
+}
+
+func trafficFor(t *testing.T, email string) xray.ClientTraffic {
+	t.Helper()
+	var row xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("read traffic for %s: %v", email, err)
+	}
+	return row
+}
+
+func recordFor(t *testing.T, email string) model.ClientRecord {
+	t.Helper()
+	var rec model.ClientRecord
+	if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
+		t.Fatalf("read record for %s: %v", email, err)
+	}
+	return rec
+}
+
+func TestPeriodicTrafficResetClients(t *testing.T) {
+	t.Run("resets a client on its own cycle inside a never-reset inbound", func(t *testing.T) {
+		initResetJobDB(t)
+		seedClientOnCycle(t, 41001, seededClient{email: "[email protected]", cycle: "weekly", day: 1, recordEnable: true, quotaEnable: true})
+		seedClientOnCycle(t, 41002, seededClient{email: "[email protected]", cycle: "monthly", day: 1, recordEnable: true, quotaEnable: true})
+
+		NewPeriodicTrafficResetJob("weekly", time.UTC).Run()
+
+		if row := trafficFor(t, "[email protected]"); row.Up != 0 || row.Down != 0 {
+			t.Fatalf("weekly client not reset by the weekly run: up=%d down=%d", row.Up, row.Down)
+		}
+		if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
+			t.Fatalf("monthly client reset by the weekly run: up=%d down=%d", row.Up, row.Down)
+		}
+	})
+
+	t.Run("leaves a client with no cycle of its own alone", func(t *testing.T) {
+		initResetJobDB(t)
+		seedClientOnCycle(t, 41003, seededClient{email: "[email protected]", cycle: "never", day: 1, recordEnable: true, quotaEnable: true})
+
+		for _, period := range []Period{"hourly", "daily", "weekly", "monthly"} {
+			NewPeriodicTrafficResetJob(period, time.UTC).Run()
+		}
+
+		if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
+			t.Fatalf("client with trafficReset=never was reset: up=%d down=%d", row.Up, row.Down)
+		}
+	})
+
+	t.Run("monthly client waits for its own day", func(t *testing.T) {
+		initResetJobDB(t)
+		today := time.Now().In(time.UTC).Day()
+		otherDay := today%28 + 1
+		seedClientOnCycle(t, 41004, seededClient{email: "[email protected]", cycle: "monthly", day: today, recordEnable: true, quotaEnable: true})
+		seedClientOnCycle(t, 41005, seededClient{email: "[email protected]", cycle: "monthly", day: otherDay, recordEnable: true, quotaEnable: true})
+
+		NewPeriodicTrafficResetJob("monthly", time.UTC).Run()
+
+		if row := trafficFor(t, "[email protected]"); row.Up != 0 || row.Down != 0 {
+			t.Fatalf("client due today was not reset: up=%d down=%d", row.Up, row.Down)
+		}
+		if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
+			t.Fatalf("client due on another day was reset: up=%d down=%d", row.Up, row.Down)
+		}
+	})
+
+	t.Run("restores a client the quota switched off", func(t *testing.T) {
+		initResetJobDB(t)
+		// Depletion disables all three of client_traffics.enable, clients.enable
+		// and the settings JSON, so a reset that lifts only the first leaves the
+		// client out of the running core with nothing left to revisit it.
+		seedClientOnCycle(t, 41006, seededClient{
+			email: "[email protected]", cycle: "daily", day: 1,
+			recordEnable: false, quotaEnable: false, total: 1000,
+		})
+
+		NewPeriodicTrafficResetJob("daily", time.UTC).Run()
+
+		if row := trafficFor(t, "[email protected]"); !row.Enable {
+			t.Fatal("quota gate not lifted: the client cannot use its new allowance")
+		}
+		if rec := recordFor(t, "[email protected]"); !rec.Enable {
+			t.Fatal("clients.enable still false: GetXrayConfig skips the client, so it stays locked out for good")
+		}
+		if enabled := settingsEnableOf(t, 41006); !enabled {
+			t.Fatal("the inbound settings JSON still has the client disabled")
+		}
+	})
+
+	t.Run("leaves a client the operator switched off", func(t *testing.T) {
+		initResetJobDB(t)
+		// Disabled with usage below quota: nothing but a human did that.
+		seedClientOnCycle(t, 41007, seededClient{
+			email: "[email protected]", cycle: "daily", day: 1,
+			recordEnable: false, quotaEnable: true, total: 100000,
+		})
+
+		NewPeriodicTrafficResetJob("daily", time.UTC).Run()
+
+		if rec := recordFor(t, "[email protected]"); rec.Enable {
+			t.Fatal("an operator-disabled client was switched back on by a cron job")
+		}
+		if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
+			t.Fatalf("an operator-disabled client was reset anyway: up=%d down=%d", row.Up, row.Down)
+		}
+	})
+}
+
+func settingsEnableOf(t *testing.T, port int) bool {
+	t.Helper()
+	var stored model.Inbound
+	if err := database.GetDB().Where("port = ?", port).First(&stored).Error; err != nil {
+		t.Fatal(err)
+	}
+	var settings struct {
+		Clients []model.Client `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
+		t.Fatalf("parse inbound settings: %v", err)
+	}
+	if len(settings.Clients) != 1 {
+		t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
+	}
+	return settings.Clients[0].Enable
+}

+ 61 - 1
internal/web/job/periodic_traffic_reset_job.go

@@ -14,6 +14,7 @@ type Period string
 type PeriodicTrafficResetJob struct {
 	inboundService service.InboundService
 	clientService  service.ClientService
+	xrayService    service.XrayService
 	period         Period
 	location       *time.Location
 }
@@ -34,8 +35,14 @@ func monthlyResetDue(resetDay int, now time.Time) bool {
 	return now.Day() == min(resetDay, lastDay)
 }
 
-// Run resets traffic statistics for all inbounds that match the configured reset period.
+// Run resets traffic statistics for all inbounds that match the configured reset
+// period, then for the clients carrying that period on their own (#5497).
 func (j *PeriodicTrafficResetJob) Run() {
+	j.resetInboundsOnSchedule()
+	j.resetClientsOnTheirOwnCycle()
+}
+
+func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
 	inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
 	if err != nil {
 		logger.Warning("Failed to get inbounds for traffic reset:", err)
@@ -78,3 +85,56 @@ func (j *PeriodicTrafficResetJob) Run() {
 		logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
 	}
 }
+
+// resetClientsOnTheirOwnCycle resets clients whose cycle is set individually. A
+// client inside an inbound on the same cycle is reset twice, which is harmless.
+func (j *PeriodicTrafficResetJob) resetClientsOnTheirOwnCycle() {
+	cycles, err := j.clientService.GetClientsByTrafficReset(string(j.period))
+	if err != nil {
+		logger.Warning("Failed to get clients for traffic reset:", err)
+		return
+	}
+
+	now := time.Now().In(j.location)
+	due := make([]service.ClientResetCycle, 0, len(cycles))
+	for _, c := range cycles {
+		// Monthly clients come due on their own day, the rule the inbound-level
+		// schedule already follows.
+		if j.period == "monthly" && !monthlyResetDue(c.TrafficResetDay, now) {
+			continue
+		}
+		// A reset re-enables, which is right for a client the quota switched off
+		// and wrong for one an operator switched off by hand.
+		if !c.Enable && !c.Depleted() {
+			continue
+		}
+		due = append(due, c)
+	}
+	if len(due) == 0 {
+		return
+	}
+	logger.Infof("Running periodic traffic reset job for period: %s (%d matching clients)", j.period, len(due))
+
+	resetCount := 0
+	needRestart := false
+	for _, c := range due {
+		// ResetTrafficByEmail rather than a bulk UPDATE: it is the path that also
+		// propagates to the client's node and clears the MTProto sidecar quota.
+		nr, resetErr := j.clientService.ResetTrafficByEmail(&j.inboundService, c.Email)
+		if resetErr != nil {
+			logger.Warning("Failed to reset traffic for client", c.Email, ":", resetErr)
+			continue
+		}
+		needRestart = needRestart || nr
+		resetCount++
+	}
+	// Dropping this leaves a re-enabled client absent from the running core until
+	// something unrelated restarts it.
+	if needRestart {
+		j.xrayService.SetToNeedRestart()
+	}
+
+	if resetCount > 0 {
+		logger.Infof("Periodic traffic reset completed: %d clients reset", resetCount)
+	}
+}

+ 31 - 0
internal/web/job/remote_routing_job.go

@@ -0,0 +1,31 @@
+package job
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/sub"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// RemoteRoutingJob keeps remote Happ and Clash/Mihomo routing URLs warm: all
+// network work runs here (cron + startup warm), never in a request handler.
+type RemoteRoutingJob struct {
+	settingService service.SettingService
+}
+
+func NewRemoteRoutingJob() *RemoteRoutingJob {
+	return &RemoteRoutingJob{}
+}
+
+func (j *RemoteRoutingJob) Run() {
+	happ, err := j.settingService.GetSubRoutingRules()
+	if err != nil {
+		logger.Warning("Could not read Happ routing source:", err)
+		return
+	}
+	clash, err := j.settingService.GetSubClashRules()
+	if err != nil {
+		logger.Warning("Could not read Clash routing source:", err)
+		return
+	}
+	sub.RefreshRemoteRoutingSources(happ, clash)
+}

+ 42 - 0
internal/web/service/calendar_renew.go

@@ -0,0 +1,42 @@
+package service
+
+import "time"
+
+// nextCalendarRenewal returns the next renewal strictly after from, at midnight
+// in loc; a missing day clamps to the month's last, so the 31st comes back (#6106).
+func nextCalendarRenewal(from time.Time, day int, loc *time.Location) time.Time {
+	if loc == nil {
+		loc = time.UTC
+	}
+	if day < 1 {
+		day = 1
+	}
+	if day > 31 {
+		day = 31
+	}
+	local := from.In(loc)
+
+	candidate := calendarDay(local.Year(), local.Month(), day, loc)
+	if !candidate.After(local) {
+		year, month := local.Year(), local.Month()+1
+		if month > time.December {
+			year, month = year+1, time.January
+		}
+		candidate = calendarDay(year, month, day, loc)
+	}
+	return candidate
+}
+
+// Clamped rather than normalized: time.Date rolls 31 February into March, which
+// is the drift this mode exists to avoid.
+func calendarDay(year int, month time.Month, day int, loc *time.Location) time.Time {
+	last := daysInMonth(year, month)
+	if day > last {
+		day = last
+	}
+	return time.Date(year, month, day, 0, 0, 0, 0, loc)
+}
+
+func daysInMonth(year int, month time.Month) int {
+	return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}

+ 116 - 0
internal/web/service/calendar_renew_test.go

@@ -0,0 +1,116 @@
+package service
+
+import (
+	"testing"
+	"time"
+)
+
+func TestNextCalendarRenewal_ClampsToShortMonths(t *testing.T) {
+	utc := time.UTC
+	cases := []struct {
+		name string
+		from time.Time
+		day  int
+		want time.Time
+	}{
+		{
+			name: "31st in a 28-day February",
+			from: time.Date(2026, time.January, 31, 0, 0, 0, 0, utc),
+			day:  31,
+			want: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "31st returns to the 31st after the short month",
+			from: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc),
+			day:  31,
+			want: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "29th in a leap February",
+			from: time.Date(2028, time.January, 29, 0, 0, 0, 0, utc),
+			day:  29,
+			want: time.Date(2028, time.February, 29, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "31st in a 30-day month",
+			from: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc),
+			day:  31,
+			want: time.Date(2026, time.April, 30, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "December rolls into January",
+			from: time.Date(2026, time.December, 5, 0, 0, 0, 0, utc),
+			day:  5,
+			want: time.Date(2027, time.January, 5, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "later in the same month renews this month",
+			from: time.Date(2026, time.June, 3, 12, 0, 0, 0, utc),
+			day:  20,
+			want: time.Date(2026, time.June, 20, 0, 0, 0, 0, utc),
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := nextCalendarRenewal(tc.from, tc.day, utc)
+			if !got.Equal(tc.want) {
+				t.Fatalf("next renewal = %s, want %s", got.Format(time.RFC3339), tc.want.Format(time.RFC3339))
+			}
+		})
+	}
+}
+
+// The renewal instant is midnight in the panel's zone, not in UTC: an operator
+// billing on the 1st expects the period to turn over at their local midnight.
+func TestNextCalendarRenewal_UsesPanelZone(t *testing.T) {
+	loc, err := time.LoadLocation("Asia/Tehran")
+	if err != nil {
+		t.Skip("zone database unavailable")
+	}
+	from := time.Date(2026, time.June, 15, 12, 0, 0, 0, time.UTC)
+	got := nextCalendarRenewal(from, 1, loc)
+
+	if got.Location() != loc {
+		t.Fatalf("renewal computed in %s, want the panel zone", got.Location())
+	}
+	y, m, d := got.Date()
+	if y != 2026 || m != time.July || d != 1 {
+		t.Fatalf("renewal date = %04d-%02d-%02d, want 2026-07-01", y, m, d)
+	}
+	if h, mi, s := got.Clock(); h != 0 || mi != 0 || s != 0 {
+		t.Fatalf("renewal at %02d:%02d:%02d, want local midnight", h, mi, s)
+	}
+}
+
+// Crossing a DST boundary must still land on local midnight rather than
+// drifting an hour, which a fixed 24h*N step cannot promise.
+func TestNextCalendarRenewal_SurvivesDaylightSaving(t *testing.T) {
+	loc, err := time.LoadLocation("Europe/Berlin")
+	if err != nil {
+		t.Skip("zone database unavailable")
+	}
+	// Berlin moves to summer time on 29 March 2026.
+	from := time.Date(2026, time.March, 10, 0, 0, 0, 0, loc)
+	got := nextCalendarRenewal(from, 10, loc)
+
+	if h, mi, _ := got.Clock(); h != 0 || mi != 0 {
+		t.Fatalf("renewal at %02d:%02d local, want midnight across the DST change", h, mi)
+	}
+	if got.Day() != 10 || got.Month() != time.April {
+		t.Fatalf("renewal = %s, want 10 April", got.Format(time.RFC3339))
+	}
+}
+
+func TestNextCalendarRenewal_AlwaysMovesForward(t *testing.T) {
+	utc := time.UTC
+	from := time.Date(2026, time.May, 20, 0, 0, 0, 0, utc)
+	// Same day: the boundary has already passed today, so the next one is a
+	// month away rather than the instant we started from.
+	got := nextCalendarRenewal(from, 20, utc)
+	if !got.After(from) {
+		t.Fatalf("next renewal %s is not after %s", got, from)
+	}
+	if got.Month() != time.June {
+		t.Fatalf("next renewal = %s, want June", got.Format(time.RFC3339))
+	}
+}

+ 13 - 1
internal/web/service/client_bulk.go

@@ -1148,6 +1148,18 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 			skip(email, verr.Error())
 			continue
 		}
+		if verr := validateClientResetDay(client.ResetDay); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientResetMax(client.ResetMax); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientTrafficReset(client.TrafficReset, client.TrafficResetDay); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
 		if len(payloads[i].InboundIds) == 0 {
 			skip(email, "at least one inbound is required")
 			continue
@@ -1324,7 +1336,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
 	db := database.GetDB()
 	now := time.Now().UnixMilli()
-	depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+	depletedClause := depletedClientsClause
 
 	var rows []xray.ClientTraffic
 	if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {

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

@@ -43,6 +43,29 @@ func validateClientSubID(subID string) error {
 	return nil
 }
 
+// Rejected rather than coerced: an unknown cycle would leave the operator with
+// a field that reads as configured while no job ever selects the client.
+func validateClientTrafficReset(period string, day int) error {
+	switch period {
+	case "", "never", "hourly", "daily", "weekly", "monthly":
+	default:
+		return common.NewError("client trafficReset must be never, hourly, daily, weekly or monthly, got:", period)
+	}
+	if day < 0 || day > 31 {
+		return common.NewError("client trafficResetDay must be between 0 and 31, got:", day)
+	}
+	return nil
+}
+
+// Rejected rather than clamped: nextCalendarRenewal would silently move an
+// out-of-range day, and a negative one drops out of the renewal query entirely.
+func validateClientResetDay(day int) error {
+	if day < 0 || day > 31 {
+		return common.NewError("client resetDay must be between 0 and 31, got:", day)
+	}
+	return nil
+}
+
 // Rejected rather than coerced: a negative cap reads as "unlimited" to a caller
 // but selects nothing, so the client would silently stop renewing.
 func validateClientResetMax(resetMax int) error {
@@ -52,6 +75,46 @@ func validateClientResetMax(resetMax int) error {
 	return nil
 }
 
+// normalizeClientTrafficReset stores what the inbound path would store, so the
+// day never reaches the DB as a 0 that three layers downstream each clamp to 1.
+func normalizeClientTrafficReset(c *model.Client) {
+	if c.TrafficReset == "" {
+		c.TrafficReset = "never"
+	}
+	c.TrafficResetDay = normalizeTrafficResetDay(c.TrafficResetDay)
+}
+
+// ClientResetCycle is the slice of a client the reset job needs: enough to know
+// whether it is due, and whether its disable is the quota's doing or the operator's.
+type ClientResetCycle struct {
+	Email           string
+	TrafficResetDay int
+	Enable          bool
+	Total           int64
+	Used            int64
+}
+
+// Depleted reports a client the quota switched off. A reset restores that one;
+// a client disabled below its quota was switched off by hand and stays off.
+func (c ClientResetCycle) Depleted() bool {
+	return c.Total > 0 && c.Used >= c.Total
+}
+
+// GetClientsByTrafficReset returns the clients whose own reset cycle matches the
+// period, independent of the cycle configured on the inbounds they belong to.
+func (s *ClientService) GetClientsByTrafficReset(period string) ([]ClientResetCycle, error) {
+	var cycles []ClientResetCycle
+	err := database.GetDB().Table("clients c").
+		Select("c.email, c.traffic_reset_day, c.enable, COALESCE(ct.total, 0) AS total, COALESCE(ct.up, 0) + COALESCE(ct.down, 0) AS used").
+		Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
+		Where("c.traffic_reset = ?", period).
+		Scan(&cycles).Error
+	if err != nil {
+		return nil, err
+	}
+	return cycles, nil
+}
+
 func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
 	if payload == nil {
 		return false, common.NewError("empty payload")
@@ -66,9 +129,16 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 	if err := validateClientSubID(client.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetDay(client.ResetDay); err != nil {
+		return false, err
+	}
 	if err := validateClientResetMax(client.ResetMax); err != nil {
 		return false, err
 	}
+	if err := validateClientTrafficReset(client.TrafficReset, client.TrafficResetDay); err != nil {
+		return false, err
+	}
+	normalizeClientTrafficReset(&client)
 	if len(payload.InboundIds) == 0 {
 		return false, common.NewError("at least one inbound is required")
 	}
@@ -356,9 +426,16 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err := validateClientSubID(updated.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetDay(updated.ResetDay); err != nil {
+		return false, err
+	}
 	if err := validateClientResetMax(updated.ResetMax); err != nil {
 		return false, err
 	}
+	if err := validateClientTrafficReset(updated.TrafficReset, updated.TrafficResetDay); err != nil {
+		return false, err
+	}
+	normalizeClientTrafficReset(&updated)
 	if updated.SubID == "" {
 		updated.SubID = existing.SubID
 	}
@@ -481,7 +558,10 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 				"tg_id":             merged.TgID,
 				"comment":           merged.Comment,
 				"reset":             merged.Reset,
+				"reset_day":         merged.ResetDay,
 				"reset_max":         merged.ResetMax,
+				"traffic_reset":     merged.TrafficReset,
+				"traffic_reset_day": merged.TrafficResetDay,
 			}).Error; err != nil {
 			return needRestart, err
 		}

+ 35 - 7
internal/web/service/client_external_link.go

@@ -14,9 +14,12 @@ import (
 
 // ExternalLinkInput is one row from the client form's Links tab.
 type ExternalLinkInput struct {
-	Kind   string `json:"kind"`
-	Value  string `json:"value"`
-	Remark string `json:"remark"`
+	Kind       string `json:"kind"`
+	Value      string `json:"value"`
+	Remark     string `json:"remark"`
+	Enable     *bool  `json:"enable"`
+	ExpiryTime int64  `json:"expiryTime"`
+	NamePrefix string `json:"namePrefix"`
 }
 
 func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) {
@@ -55,11 +58,21 @@ func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalL
 		default:
 			return nil, common.NewError("unknown external link kind: " + kind)
 		}
+		if in.ExpiryTime < 0 {
+			return nil, common.NewError("external link expiryTime must be 0 (never) or a future unix millisecond timestamp: " + value)
+		}
+		enable := true
+		if in.Enable != nil {
+			enable = *in.Enable
+		}
 		out = append(out, model.ClientExternalLink{
-			Kind:      kind,
-			Value:     value,
-			Remark:    strings.TrimSpace(in.Remark),
-			SortIndex: len(out),
+			Kind:       kind,
+			Value:      value,
+			Remark:     strings.TrimSpace(in.Remark),
+			Enable:     &enable,
+			ExpiryTime: in.ExpiryTime,
+			NamePrefix: in.NamePrefix,
+			SortIndex:  len(out),
 		})
 	}
 	return out, nil
@@ -78,10 +91,25 @@ func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkI
 	}
 	db := database.GetDB()
 	return db.Transaction(func(tx *gorm.DB) error {
+		var existing []model.ClientExternalLink
+		if err := tx.Where("client_id = ?", id).Find(&existing).Error; err != nil {
+			return err
+		}
+		byKindValue := make(map[string]model.ClientExternalLink, len(existing))
+		for _, row := range existing {
+			key := row.Kind + "\x00" + row.Value
+			if _, ok := byKindValue[key]; !ok {
+				byKindValue[key] = row
+			}
+		}
 		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
 			return err
 		}
 		for i := range rows {
+			if old, ok := byKindValue[rows[i].Kind+"\x00"+rows[i].Value]; ok {
+				rows[i].LastFetchAt = old.LastFetchAt
+				rows[i].LastFetchError = old.LastFetchError
+			}
 			rows[i].ClientId = id
 			if err := tx.Create(&rows[i]).Error; err != nil {
 				return err

+ 124 - 0
internal/web/service/client_external_link_test.go

@@ -0,0 +1,124 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func externalLinkBool(v bool) *bool {
+	return &v
+}
+
+func TestSetExternalLinksPersistsEnableState(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	svc := &ClientService{}
+
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-links", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+
+	if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#on", Remark: "Primary", Enable: externalLinkBool(true), ExpiryTime: 1767225600000},
+		{Kind: model.ExternalLinkKindSubscription, Value: "https://provider.example/sub", Remark: "Provider", Enable: externalLinkBool(false), NamePrefix: "[zjh] "},
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#default"},
+	}); err != nil {
+		t.Fatalf("set external links: %v", err)
+	}
+
+	rows, err := svc.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("get external links: %v", err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("rows = %d, want 3", len(rows))
+	}
+	if rows[0].Enable == nil || *rows[0].Enable != true {
+		t.Fatalf("first row enable = %#v, want true", rows[0].Enable)
+	}
+	if rows[1].Enable == nil || *rows[1].Enable != false {
+		t.Fatalf("second row enable = %#v, want false", rows[1].Enable)
+	}
+	if rows[2].Enable == nil || *rows[2].Enable != true {
+		t.Fatalf("omitted enable should default true, got %#v", rows[2].Enable)
+	}
+	if rows[0].Remark != "Primary" || rows[0].ExpiryTime != 1767225600000 {
+		t.Fatalf("first row fields not persisted: %#v", rows[0])
+	}
+	if rows[1].Remark != "Provider" || rows[1].NamePrefix != "[zjh] " {
+		t.Fatalf("subscription fields not persisted: %#v", rows[1])
+	}
+}
+
+func TestSetExternalLinksPreservesFetchStatus(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	svc := &ClientService{}
+
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-status", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	row := model.ClientExternalLink{
+		ClientId:       rec.Id,
+		Kind:           model.ExternalLinkKindSubscription,
+		Value:          "https://provider.example/sub",
+		Remark:         "old",
+		LastFetchAt:    1767220000000,
+		LastFetchError: "timeout",
+		SortIndex:      0,
+	}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatalf("create external link: %v", err)
+	}
+
+	if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
+		{Kind: row.Kind, Value: row.Value, Remark: "new", Enable: externalLinkBool(true)},
+	}); err != nil {
+		t.Fatalf("set external links: %v", err)
+	}
+
+	rows, err := svc.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("get external links: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("rows = %d, want 1", len(rows))
+	}
+	if rows[0].LastFetchAt != row.LastFetchAt || rows[0].LastFetchError != row.LastFetchError {
+		t.Fatalf("fetch status not preserved: %#v", rows[0])
+	}
+	if rows[0].Remark != "new" {
+		t.Fatalf("editable fields not updated: %#v", rows[0])
+	}
+}
+
+func TestSetExternalLinksRejectsNegativeExpiry(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	svc := &ClientService{}
+
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-negative", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+
+	err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#neg", ExpiryTime: -86400000},
+	})
+	want := "external link expiryTime must be 0 (never) or a future unix millisecond timestamp: trojan://[email protected]:443#neg\n"
+	if err == nil || err.Error() != want {
+		t.Fatalf("err = %v, want %q", err, want)
+	}
+
+	rows, err := svc.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("get external links: %v", err)
+	}
+	if len(rows) != 0 {
+		t.Fatalf("rows = %d, want the rejected save to persist nothing", len(rows))
+	}
+}

+ 9 - 0
internal/web/service/client_link.go

@@ -63,7 +63,16 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
 	}
 	row.Comment = incoming.Comment
 	row.Reset = incoming.Reset
+	row.ResetDay = incoming.ResetDay
 	row.ResetMax = incoming.ResetMax
+	// Guarded like Group and AdTag: a node snapshot rebuilt from settings that
+	// predate the cycle would otherwise silently erase it.
+	if incoming.TrafficReset != "" {
+		row.TrafficReset = incoming.TrafficReset
+	}
+	if incoming.TrafficResetDay > 0 {
+		row.TrafficResetDay = incoming.TrafficResetDay
+	}
 	if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
 		row.CreatedAt = incoming.CreatedAt
 	}

+ 4 - 2
internal/web/service/client_paging.go

@@ -26,6 +26,7 @@ type ClientSlim struct {
 	LimitIP    int                 `json:"limitIp"`
 	LimitHwid  int                 `json:"limitHwid"`
 	Reset      int                 `json:"reset"`
+	ResetDay   int                 `json:"resetDay"`
 	ResetMax   int                 `json:"resetMax"`
 	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
@@ -246,9 +247,9 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
 	}
 	switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
 	case "on":
-		where("COALESCE(c.reset, 0) > 0")
+		where("(COALESCE(c.reset, 0) > 0 OR COALESCE(c.reset_day, 0) > 0)")
 	case "off":
-		where("COALESCE(c.reset, 0) <= 0")
+		where("(COALESCE(c.reset, 0) <= 0 AND COALESCE(c.reset_day, 0) <= 0)")
 	}
 	switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
 	case "yes":
@@ -606,6 +607,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
 		LimitIP:    c.LimitIP,
 		LimitHwid:  c.LimitHwid,
 		Reset:      c.Reset,
+		ResetDay:   c.ResetDay,
 		ResetMax:   c.ResetMax,
 		Group:      c.Group,
 		Comment:    c.Comment,

+ 12 - 0
internal/web/service/client_portable.go

@@ -115,6 +115,18 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 			skip(email, verr.Error())
 			continue
 		}
+		if verr := validateClientResetDay(client.ResetDay); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientResetMax(client.ResetMax); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientTrafficReset(client.TrafficReset, client.TrafficResetDay); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
 
 		// An existing record (in the DB or just created from the attached set
 		// above) always wins — import never clobbers a live client.

+ 154 - 0
internal/web/service/client_traffic_cycle_test.go

@@ -0,0 +1,154 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// The cycle has to survive the clients table, not just the settings JSON: an
+// ordinary edit rebuilds the client from the record and writes it back (#5497).
+func TestClientEditKeepsTheTrafficResetCycle(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{
+			Email: "cyc@x", ID: "66666666-6666-6666-6666-666666666666", Enable: true,
+			TrafficReset: "monthly", TrafficResetDay: 15,
+			ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
+		},
+	}
+	ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "cyc@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.TrafficReset != "monthly" || rec.TrafficResetDay != 15 {
+		t.Fatalf("clients row holds %q/%d, want monthly/15", rec.TrafficReset, rec.TrafficResetDay)
+	}
+
+	// What the edit dialog does: hydrate the record, change something else, save.
+	edited := rec.ToClient()
+	edited.Comment = "renamed"
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	var stored model.Inbound
+	if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
+		t.Fatal(err)
+	}
+	var settings struct {
+		Clients []model.Client `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
+		t.Fatalf("parse inbound settings: %v", err)
+	}
+	if len(settings.Clients) != 1 {
+		t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
+	}
+	if got := settings.Clients[0]; got.TrafficReset != "monthly" || got.TrafficResetDay != 15 {
+		t.Fatalf("inbound settings hold %q/%d after an unrelated edit, want monthly/15: the cycle was silently switched off",
+			got.TrafficReset, got.TrafficResetDay)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "cyc@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.TrafficReset != "monthly" || rec.TrafficResetDay != 15 {
+		t.Fatalf("clients row holds %q/%d after an unrelated edit, want monthly/15", rec.TrafficReset, rec.TrafficResetDay)
+	}
+}
+
+// The setting is useless if it can only be chosen once. This is the assertion
+// the earlier "survives an unrelated edit" test could not make: that one passed
+// precisely because nothing on the attached-inbound path ever wrote the column.
+func TestClientEditChangesTheTrafficResetCycle(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+
+	clients := []model.Client{
+		{
+			Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true,
+			TrafficReset: "weekly", TrafficResetDay: 1,
+			ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
+		},
+	}
+	ib := mkInbound(t, 30302, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+
+	edited := rec.ToClient()
+	edited.TrafficReset = "monthly"
+	edited.TrafficResetDay = 9
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.TrafficReset != "monthly" || rec.TrafficResetDay != 9 {
+		t.Fatalf("clients row holds %q/%d after the operator changed it to monthly/9: the job keeps applying the old cycle",
+			rec.TrafficReset, rec.TrafficResetDay)
+	}
+
+	// Turning it off has to work too, and "never" is not an empty value.
+	edited = rec.ToClient()
+	edited.TrafficReset = "never"
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update to never: %v", err)
+	}
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after disabling: %v", err)
+	}
+	if rec.TrafficReset != "never" {
+		t.Fatalf("clients row holds %q after the operator switched the cycle off", rec.TrafficReset)
+	}
+}
+
+// An unknown cycle would leave a field that reads as configured while no job
+// ever selects the client, so it is rejected instead of coerced.
+func TestClientTrafficResetValidation(t *testing.T) {
+	for _, tc := range []struct {
+		name   string
+		period string
+		day    int
+		ok     bool
+	}{
+		{"unset", "", 0, true},
+		{"never", "never", 1, true},
+		{"monthly last day", "monthly", 31, true},
+		{"unknown period", "fortnightly", 1, false},
+		{"day past the month", "monthly", 32, false},
+		{"negative day", "monthly", -1, false},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			err := validateClientTrafficReset(tc.period, tc.day)
+			if tc.ok && err != nil {
+				t.Errorf("validateClientTrafficReset(%q, %d) = %v, want accepted", tc.period, tc.day, err)
+			}
+			if !tc.ok && err == nil {
+				t.Errorf("validateClientTrafficReset(%q, %d) accepted, want rejected", tc.period, tc.day)
+			}
+		})
+	}
+}

+ 17 - 0
internal/web/service/depleted_calendar_test.go

@@ -0,0 +1,17 @@
+package service
+
+import (
+	"strings"
+	"testing"
+)
+
+// A calendar client has reset = 0, so the old predicate called it depleted at all
+// times and the operator's purge deleted it along with its traffic row (#6239).
+func TestDepletedClauseExcludesCalendarClients(t *testing.T) {
+	if !strings.Contains(depletedClientsClause, "reset_day = 0") {
+		t.Fatalf("predicate ignores reset_day, so a calendar client would be purged: %q", depletedClientsClause)
+	}
+	if !strings.Contains(depletedClientsClause, "reset = 0") {
+		t.Fatalf("predicate no longer protects interval clients: %q", depletedClientsClause)
+	}
+}

+ 55 - 5
internal/web/service/import_host_settings_test.go

@@ -70,16 +70,15 @@ func TestImportKeepsHostBoundSettings(t *testing.T) {
 	}
 }
 
-// The destination usually has no row at all for the certificate paths and the
-// node identity — the built-in default applies. The imported row must go, or
-// the panel quietly adopts the source machine's certificate path.
+// A certificate path this machine never set must not be inherited from the
+// source. Lazily minted material is the opposite case and is covered below.
 func TestImportDropsHostBoundSettingsThisMachineNeverHad(t *testing.T) {
 	setupConflictDB(t)
 	db := database.GetDB()
 
 	kept := captureHostBoundSettings()
 
-	for _, key := range []string{"webCertFile", "subCertFile", "nodeMtlsClientCertPem"} {
+	for _, key := range []string{"webCertFile", "subCertFile"} {
 		if err := db.Create(&model.Setting{Key: key, Value: "from-imported-file"}).Error; err != nil {
 			t.Fatalf("seed imported %s: %v", key, err)
 		}
@@ -87,7 +86,7 @@ func TestImportDropsHostBoundSettingsThisMachineNeverHad(t *testing.T) {
 
 	restoreHostBoundSettings(kept)
 
-	for _, key := range []string{"webCertFile", "subCertFile", "nodeMtlsClientCertPem"} {
+	for _, key := range []string{"webCertFile", "subCertFile"} {
 		var count int64
 		if err := db.Model(&model.Setting{}).Where("key = ?", key).Count(&count).Error; err != nil {
 			t.Fatal(err)
@@ -97,3 +96,54 @@ func TestImportDropsHostBoundSettingsThisMachineNeverHad(t *testing.T) {
 		}
 	}
 }
+
+// Node mTLS material is minted on demand, so a fresh install has no row and the
+// imported copy is the only one there is — including the CA private key (#6227).
+func TestImportKeepsLazilyMintedMaterialThisMachineNeverHad(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	kept := captureHostBoundSettings()
+
+	for _, key := range []string{"nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCertPem", "nodeMtlsClientKeyPem", "nodeMtlsClientCAPem"} {
+		if err := db.Create(&model.Setting{Key: key, Value: "from-imported-file"}).Error; err != nil {
+			t.Fatalf("seed imported %s: %v", key, err)
+		}
+	}
+
+	restoreHostBoundSettings(kept)
+
+	for _, key := range []string{"nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCertPem", "nodeMtlsClientKeyPem", "nodeMtlsClientCAPem"} {
+		var got model.Setting
+		if err := db.Where("key = ?", key).First(&got).Error; err != nil {
+			t.Fatalf("%s was dropped; restoring a backup onto a reinstalled panel would lose it: %v", key, err)
+		}
+	}
+}
+
+// An empty local value is the normal state once Panel Settings has been saved:
+// GORM's Assign(struct) dropped it, so the source machine's path survived.
+func TestImportRestoresEmptyLocalValueOverImported(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	if err := db.Create(&model.Setting{Key: "webCertFile", Value: ""}).Error; err != nil {
+		t.Fatalf("seed local empty: %v", err)
+	}
+	kept := captureHostBoundSettings()
+
+	if err := db.Model(&model.Setting{}).Where("key = ?", "webCertFile").
+		Update("value", "/etc/ssl/source-host.pem").Error; err != nil {
+		t.Fatalf("seed imported: %v", err)
+	}
+
+	restoreHostBoundSettings(kept)
+
+	var got model.Setting
+	if err := db.Where("key = ?", "webCertFile").First(&got).Error; err != nil {
+		t.Fatal(err)
+	}
+	if got.Value != "" {
+		t.Fatalf("webCertFile = %q, want the empty local value back: the panel still points at the source machine's certificate", got.Value)
+	}
+}

+ 391 - 0
internal/web/service/inbound_autorenew_calendar_test.go

@@ -0,0 +1,391 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// pinPanelZone fixes the panel time zone so the assertions below can talk about
+// calendar days without the test machine's own zone shifting them.
+func pinPanelZone(t *testing.T, name string) *time.Location {
+	t.Helper()
+	loc, err := time.LoadLocation(name)
+	if err != nil {
+		t.Skipf("zone database unavailable: %v", err)
+	}
+	if err := database.GetDB().Create(&model.Setting{Key: "timeLocation", Value: name}).Error; err != nil {
+		t.Fatalf("pin panel zone: %v", err)
+	}
+	return loc
+}
+
+// Calendar mode renews on the same day each month. The interval mode drifts —
+// 30 days from 31 January is 2 March — which is the whole reason for the mode.
+func TestAutoRenewClients_CalendarModeLandsOnTheBillingDay(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+	zone := pinPanelZone(t, "UTC")
+
+	// Expired two calendar months ago, billed on the 15th.
+	past := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC)
+	clients := []model.Client{
+		{Email: "cal@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, ResetDay: 15, ExpiryTime: past.UnixMilli()},
+	}
+	ib := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "cal@x", Enable: false, Up: 5, Down: 6,
+		ResetDay: 15, ExpiryTime: past.UnixMilli(),
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1", count)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "cal@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	got := time.UnixMilli(row.ExpiryTime).In(zone)
+	if got.Day() != 15 {
+		t.Fatalf("renewed to %s, want the 15th: calendar mode must not drift", got.Format(time.RFC3339))
+	}
+	if !got.After(time.Now()) {
+		t.Fatalf("renewed to %s, which is not in the future", got.Format(time.RFC3339))
+	}
+	if h, m, s := got.Clock(); h != 0 || m != 0 || s != 0 {
+		t.Fatalf("renewed to %02d:%02d:%02d, want midnight", h, m, s)
+	}
+	if row.Up != 0 || row.Down != 0 {
+		t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down)
+	}
+	if !row.Enable {
+		t.Fatal("a renewed client must be re-enabled")
+	}
+}
+
+// A client billed on the 31st keeps that day, borrowing the last day only in
+// months that are too short for it.
+func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+	zone := pinPanelZone(t, "UTC")
+
+	past := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
+	clients := []model.Client{
+		{Email: "eom@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli()},
+	}
+	ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "eom@x", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli(),
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "eom@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	got := time.UnixMilli(row.ExpiryTime).In(zone)
+	want := firstBillingMidnightAfter(t, time.Now().In(zone), 31, zone)
+	if !got.Equal(want) {
+		t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
+	}
+}
+
+// Deliberately not built on nextCalendarRenewal: it walks a day at a time and
+// derives month length from time.Date's own zero-day trick, so it can disagree.
+func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time {
+	t.Helper()
+	cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
+	for i := 0; i < 400; i++ {
+		cur = cur.AddDate(0, 0, 1)
+		want := day
+		if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last {
+			want = last
+		}
+		if cur.Day() == want {
+			return cur
+		}
+	}
+	t.Fatalf("no billing midnight for day %d within a year of %s", day, from)
+	return time.Time{}
+}
+
+// Interval clients must be untouched by the new field.
+func TestAutoRenewClients_IntervalModeUnchanged(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "days@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30203, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "days@x", Enable: false, Reset: 30, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1", count)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "days@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if want := past + 30*86400000; row.ExpiryTime != want {
+		t.Fatalf("interval renewal moved to %d, want the old fixed step %d", row.ExpiryTime, want)
+	}
+}
+
+// The selection filter is what keeps a row with neither mode configured out of
+// the renewal loop. That matters more than it looks: the interval step is
+// reset*24h, so a zero interval reaching that loop would spin forever on the
+// single traffic writer. The guard in the loop is a second line of defence and
+// is deliberately unreachable while this filter holds.
+func TestAutoRenewClients_RowWithNoModeIsNotSelected(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "none@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30204, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	// Seeded straight into the table with both modes off, the shape the
+	// selection filter is supposed to exclude.
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "none@x", Enable: false, Reset: 0, ResetDay: 0, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	// Asserted against the query rather than by running the loop: the failure
+	// this guards is a hang, and a stuck goroutine outlives the test's DB.
+	var selected int64
+	if err := db.Model(&xray.ClientTraffic{}).
+		Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", time.Now().UnixMilli()).
+		Where("email = ?", "none@x").
+		Count(&selected).Error; err != nil {
+		t.Fatal(err)
+	}
+	if selected != 0 {
+		t.Fatal("a row with no renewal mode was selected for renewal: it would reach the interval loop and spin forever")
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 0 {
+		t.Fatalf("renewed count = %d, want 0", count)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "none@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ExpiryTime != past {
+		t.Fatalf("a client with no renewal mode was renewed to %d", row.ExpiryTime)
+	}
+}
+
+// The billing day has to survive the clients table, not just the settings JSON:
+// an ordinary edit rebuilds the client from the record and writes it back (#6106).
+func TestClientEditKeepsTheBillingDay(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "keep@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, ResetDay: 20, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
+	}
+	ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	mkTraffic(t, ib.Id, "keep@x", 10, 20, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "keep@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.ResetDay != 20 {
+		t.Fatalf("clients.reset_day = %d, want the 20 the client was created with", rec.ResetDay)
+	}
+
+	// What the edit dialog does: hydrate the record, change something else, save.
+	edited := rec.ToClient()
+	edited.Comment = "renamed"
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	// The inbound JSON is what xray and the edit dialog read back, and it is
+	// rebuilt from the record, so it is where a dropped converter field shows.
+	var stored model.Inbound
+	if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
+		t.Fatal(err)
+	}
+	var settings struct {
+		Clients []model.Client `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
+		t.Fatalf("parse inbound settings: %v", err)
+	}
+	if len(settings.Clients) != 1 {
+		t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
+	}
+	if settings.Clients[0].ResetDay != 20 {
+		t.Fatalf("inbound settings resetDay = %d after an unrelated edit, want 20: calendar mode was silently turned off", settings.Clients[0].ResetDay)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "keep@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetDay != 20 {
+		t.Fatalf("clients.reset_day = %d after an unrelated edit, want 20", rec.ResetDay)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "keep@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ResetDay != 20 {
+		t.Fatalf("client_traffics.reset_day = %d after an unrelated edit, want 20", row.ResetDay)
+	}
+}
+
+// The billing day is useless if it can only be chosen once. The test above
+// passes even without the record write, because nothing overwrites the value
+// it checks; this one fails without it.
+func TestClientEditChangesTheBillingDay(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+
+	clients := []model.Client{
+		{
+			Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, ResetDay: 20,
+			ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
+		},
+	}
+	ib := mkInbound(t, 30206, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	edited := rec.ToClient()
+	edited.ResetDay = 5
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetDay != 5 {
+		t.Fatalf("clients.reset_day = %d after the operator moved the billing day to the 5th", rec.ResetDay)
+	}
+
+	// Turning calendar mode off has to work too.
+	edited = rec.ToClient()
+	edited.ResetDay = 0
+	edited.Reset = 30
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update back to interval mode: %v", err)
+	}
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after switching mode: %v", err)
+	}
+	if rec.ResetDay != 0 {
+		t.Fatalf("clients.reset_day = %d after the operator switched back to interval mode", rec.ResetDay)
+	}
+}
+
+// The two renewal features meet here: a calendar client is capped like an
+// interval one, spending one allowance per month rather than per tick.
+func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+	zone := pinPanelZone(t, "UTC")
+
+	// Three calendar months behind with one allowance left: a single month step
+	// cannot reach the present, so the client stays expired on its billing day.
+	past := time.Now().In(zone).AddDate(0, -3, 0)
+	past = time.Date(past.Year(), past.Month(), 10, 0, 0, 0, 0, zone)
+	clients := []model.Client{
+		{Email: "calcap@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 10, ResetMax: 3, ExpiryTime: past.UnixMilli()},
+	}
+	ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "calcap@x", Enable: false, ResetDay: 10, ResetMax: 3, ResetCount: 2,
+		Up: 111, Down: 222, ExpiryTime: past.UnixMilli(),
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "calcap@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	got := time.UnixMilli(row.ExpiryTime).In(zone)
+	if want := past.AddDate(0, 1, 0); !got.Equal(want) {
+		t.Fatalf("renewed to %s, want exactly one month on to %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
+	}
+	if row.ResetCount != 3 {
+		t.Fatalf("resetCount = %d, want 3: one allowance per month stepped", row.ResetCount)
+	}
+	if row.Enable {
+		t.Fatal("a client still expired after a truncated catch-up was enabled")
+	}
+	if row.Up != 111 || row.Down != 222 {
+		t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down)
+	}
+}

+ 8 - 8
internal/web/service/inbound_node.go

@@ -237,7 +237,7 @@ func mergeActivationExpiry(existing, node int64) int64 {
 // nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved
 // forward while the node's cumulative counter fell below the stored baseline.
 func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool {
-	if cs.Reset <= 0 || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
+	if (cs.Reset <= 0 && cs.ResetDay <= 0) || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
 		return false
 	}
 	if cs.ExpiryTime <= existing.ExpiryTime {
@@ -714,9 +714,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if dirty {
 			continue
 		}
-		// Disabled inbounds are intentionally absent from the node's runtime
-		// snapshot. Their absence is not evidence of deletion; retain the row,
-		// client history and port reservation until an explicit delete occurs.
+		// A node inbound created disabled is never delivered, so its absence from
+		// the snapshot is ambiguous rather than evidence of a node-side delete.
 		if !c.Enable {
 			continue
 		}
@@ -844,6 +843,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					Total:      cs.Total,
 					ExpiryTime: cs.ExpiryTime,
 					Reset:      cs.Reset,
+					ResetDay:   cs.ResetDay,
 					Up:         seedUp,
 					Down:       seedDown,
 					LastOnline: cs.LastOnline,
@@ -879,12 +879,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					fmt.Sprintf(
 						`UPDATE client_traffics
 						 SET up = ?, down = ?, enable = ?, total = ?,
-						     expiry_time = ?, reset = ?, last_online = %s
+						     expiry_time = ?, reset = ?, reset_day = ?, last_online = %s
 						 WHERE email = ?`,
 						database.GreatestExpr("last_online", "?"),
 					),
 					canon.Up, canon.Down, cs.Enable, cs.Total,
-					cs.ExpiryTime, cs.Reset,
+					cs.ExpiryTime, cs.Reset, cs.ResetDay,
 					cs.LastOnline, cs.Email,
 				).Error; err != nil {
 					return false, err
@@ -906,7 +906,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 						`UPDATE client_traffics
 						 SET up = %s, down = %s, enable = %s, total = ?,
 						     expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
-						     reset = ?, last_online = %s
+						     reset = ?, reset_day = ?, last_online = %s
 						 WHERE email = ?`,
 						database.ClampedAddExpr("up"),
 						database.ClampedAddExpr("down"),
@@ -914,7 +914,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 						database.GreatestExpr("last_online", "?"),
 					),
 					deltaUp, deltaDown, cs.Enable, cs.Total,
-					cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
+					cs.ExpiryTime, cs.ExpiryTime, cs.Reset, cs.ResetDay,
 					cs.LastOnline, cs.Email,
 				).Error; err != nil {
 					return false, err

+ 31 - 4
internal/web/service/inbound_traffic.go

@@ -21,6 +21,10 @@ import (
 	"gorm.io/gorm/clause"
 )
 
+// A client with a renewal day set auto-renews too, so it must not read as
+// depleted — otherwise the operator's purge deletes it between cycles (#6239).
+const depletedClientsClause = "reset = 0 and reset_day = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+
 func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
 	var disabledNodeIDs []int
 	err = submitTrafficWrite(func() error {
@@ -333,7 +337,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 	// attached to, so it could be a node inbound even when the client also has
 	// local inbounds. The email-based join through client_inbounds is authoritative.
 	err = tx.Model(xray.ClientTraffic{}).
-		Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
+		Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", now).
 		// A prepaid plan stops itself: once as many renewals have fired as the
 		// operator allowed, the client is left to expire like any other.
 		Where("reset_max <= 0 or reset_count < reset_max").
@@ -351,6 +355,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 		return false, 0, nil
 	}
 
+	renewLocation, locErr := (&SettingService{}).GetTimeLocation()
+	if locErr != nil || renewLocation == nil {
+		// Falling back to UTC keeps renewals happening; the alternative is
+		// skipping them entirely because a setting could not be read.
+		logger.Warning("autoRenewClients: could not read the panel time zone, using UTC:", locErr)
+		renewLocation = time.UTC
+	}
+
 	var inbound_ids []int
 	var inbounds []*model.Inbound
 	needRestart := false
@@ -417,12 +429,25 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 			// One allowance per period, not per tick: a client away for three
 			// cycles must not catch up three of them against a prepaid cap.
 			newExpiryTime := traffic.ExpiryTime
+			if traffic.ResetDay <= 0 && traffic.Reset <= 0 {
+				// Unreachable while the selection filter holds: a zero step below
+				// would spin forever on the single traffic writer and hang the panel.
+				continue
+			}
+			at := time.UnixMilli(newExpiryTime)
 			renewals := 0
 			for newExpiryTime < now {
 				if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
 					break
 				}
-				newExpiryTime += (int64(traffic.Reset) * 86400000)
+				if traffic.ResetDay > 0 {
+					// Calendar mode: step whole months in the panel's zone, so the
+					// renewal date does not drift the way a fixed 30-day step does.
+					at = nextCalendarRenewal(at, traffic.ResetDay, renewLocation)
+					newExpiryTime = at.UnixMilli()
+				} else {
+					newExpiryTime += (int64(traffic.Reset) * 86400000)
+				}
 				renewals++
 			}
 			if renewals == 0 {
@@ -528,11 +553,12 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
 		ExpiryTime: client.ExpiryTime,
 		Enable:     client.Enable,
 		Reset:      client.Reset,
+		ResetDay:   client.ResetDay,
 		ResetMax:   client.ResetMax,
 	}
 	return tx.Clauses(clause.OnConflict{
 		Columns:   []clause.Column{{Name: "email"}},
-		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}),
+		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_day", "reset_max"}),
 	}).Create(&clientTraffic).Error
 }
 
@@ -545,6 +571,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
 			"total":       client.TotalGB,
 			"expiry_time": client.ExpiryTime,
 			"reset":       client.Reset,
+			"reset_day":   client.ResetDay,
 			"reset_max":   client.ResetMax,
 		})
 	err := result.Error
@@ -812,7 +839,7 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
 		// Collect depleted emails globally — a shared-email row owned by one
 		// inbound depletes every sibling that lists the email.
 		now := time.Now().Unix() * 1000
-		depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+		depletedClause := depletedClientsClause
 		var depletedRows []xray.ClientTraffic
 		if err := tx.Model(xray.ClientTraffic{}).
 			Where(depletedClause, now).

+ 65 - 33
internal/web/service/reality_scan.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/tls"
 	"crypto/x509"
+	"errors"
 	"fmt"
 	"net"
 	"slices"
@@ -12,6 +13,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
@@ -38,24 +40,30 @@ var defaultRealityScanCandidates = []string{
 }
 
 type RealityScanResult struct {
-	Target      string   `json:"target" example:"www.cloudflare.com:443"`
-	Host        string   `json:"host" example:"www.cloudflare.com"`
-	IP          string   `json:"ip" example:"104.16.124.96"`
-	Port        int      `json:"port" example:"443"`
-	Feasible    bool     `json:"feasible" example:"true"`
-	TLS13       bool     `json:"tls13" example:"true"`
-	TLSVersion  string   `json:"tlsVersion" example:"1.3"`
-	H2          bool     `json:"h2" example:"true"`
-	ALPN        string   `json:"alpn" example:"h2"`
-	X25519      bool     `json:"x25519" example:"true"`
-	CurveID     string   `json:"curveID" example:"X25519"`
-	CertValid   bool     `json:"certValid" example:"true"`
-	CertSubject string   `json:"certSubject" example:"cloudflare.com"`
-	CertIssuer  string   `json:"certIssuer" example:"Google Trust Services"`
-	NotAfter    string   `json:"notAfter" example:"2026-08-01T00:00:00Z"`
-	ServerNames []string `json:"serverNames"`
-	LatencyMs   int      `json:"latencyMs" example:"180"`
-	Reason      string   `json:"reason" example:""`
+	Target   string `json:"target" example:"www.cloudflare.com:443"`
+	Host     string `json:"host" example:"www.cloudflare.com"`
+	IP       string `json:"ip" example:"104.16.124.96"`
+	Port     int    `json:"port" example:"443"`
+	Feasible bool   `json:"feasible" example:"true"`
+	// PrivateTarget marks a target that resolves to a loopback/private/link-local
+	// address: blocked before the probe unless the caller opted in, then flagged.
+	PrivateTarget bool   `json:"privateTarget" example:"false"`
+	TLS13         bool   `json:"tls13" example:"true"`
+	TLSVersion    string `json:"tlsVersion" example:"1.3"`
+	H2            bool   `json:"h2" example:"true"`
+	ALPN          string `json:"alpn" example:"h2"`
+	X25519        bool   `json:"x25519" example:"true"`
+	CurveID       string `json:"curveID" example:"X25519"`
+	CertValid     bool   `json:"certValid" example:"true"`
+	// CertChainValid ignores the name: a trusted chain presented for other names
+	// still has serverNames the panel can offer instead of the failing SNI.
+	CertChainValid bool     `json:"certChainValid" example:"true"`
+	CertSubject    string   `json:"certSubject" example:"cloudflare.com"`
+	CertIssuer     string   `json:"certIssuer" example:"Google Trust Services"`
+	NotAfter       string   `json:"notAfter" example:"2026-08-01T00:00:00Z"`
+	ServerNames    []string `json:"serverNames"`
+	LatencyMs      int      `json:"latencyMs" example:"180"`
+	Reason         string   `json:"reason" example:""`
 }
 
 type realityProbeTask struct {
@@ -126,6 +134,11 @@ func firstUsableName(leaf *x509.Certificate) string {
 	return ""
 }
 
+func leafVerifies(leaf *x509.Certificate, opts x509.VerifyOptions) bool {
+	_, err := leaf.Verify(opts)
+	return err == nil
+}
+
 func splitRealityTarget(target string) (string, int, error) {
 	target = strings.TrimSpace(target)
 	if target == "" {
@@ -170,30 +183,38 @@ func enumerateCIDR(cidr string, max int) ([]string, error) {
 	return ips, nil
 }
 
-func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int) *RealityScanResult {
+func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int, allowPrivate bool) *RealityScanResult {
 	addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
 	res := &RealityScanResult{Port: port}
 	if net.ParseIP(dialHost) != nil {
 		res.IP = dialHost
 	}
+	// Target stays the dialed address (it is what the inbound dials); Host is
+	// the SNI the handshake sent, which may differ for a fronting proxy.
+	res.Host = dialHost
+	res.Target = addr
 	if sni != "" {
 		res.Host = sni
-		res.Target = net.JoinHostPort(sni, strconv.Itoa(port))
-	} else {
-		res.Host = dialHost
-		res.Target = addr
 	}
 
-	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	ctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(context.Background(), allowPrivate), timeout)
 	defer cancel()
 
 	start := time.Now()
 	conn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", addr)
 	if err != nil {
+		res.PrivateTarget = errors.Is(err, netsafe.ErrPrivateAddressBlocked)
 		res.Reason = "connection failed: " + err.Error()
 		return res
 	}
 	defer conn.Close()
+	if remote, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
+		res.PrivateTarget = netsafe.IsBlockedIP(remote.IP)
+		// The opt-in bypasses the SSRF guard, so leave an audit trail of it.
+		if res.PrivateTarget && allowPrivate {
+			logger.Infof("reality scan reached private target %s (%s) with the operator opt-in", addr, remote.IP)
+		}
+	}
 	_ = conn.SetDeadline(time.Now().Add(timeout))
 
 	// A REALITY inbound with xver>=1 fronts a target that speaks the PROXY
@@ -253,13 +274,18 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 		}
 
 		if verifyHost != "" {
-			opts := x509.VerifyOptions{DNSName: verifyHost, Intermediates: x509.NewCertPool()}
+			opts := x509.VerifyOptions{Intermediates: x509.NewCertPool()}
 			for _, c := range st.PeerCertificates[1:] {
 				opts.Intermediates.AddCert(c)
 			}
-			if _, verr := leaf.Verify(opts); verr == nil {
+			// The chain is checked without the name first: a publicly trusted
+			// certificate for other names still carries usable serverNames.
+			res.CertChainValid = leafVerifies(leaf, opts)
+			opts.DNSName = verifyHost
+			if leafVerifies(leaf, opts) {
 				res.CertValid = true
 			} else {
+				_, verr := leaf.Verify(opts)
 				res.Reason = "certificate not trusted: " + verr.Error()
 			}
 		} else {
@@ -283,16 +309,20 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	return res
 }
 
-func (s *ServerService) probeRealityTarget(host string, port int, xver int) *RealityScanResult {
-	return s.probeRealityAddr(host, port, host, realityScanTimeout, xver)
-}
-
-func (s *ServerService) ScanRealityTarget(target string, xver int) (*RealityScanResult, error) {
+// ScanRealityTarget probes one operator-supplied target. An empty sni falls back
+// to the target host; allowPrivate lifts the SSRF guard for this probe only.
+func (s *ServerService) ScanRealityTarget(target string, sni string, xver int, allowPrivate bool) (*RealityScanResult, error) {
 	host, port, err := splitRealityTarget(target)
 	if err != nil {
 		return nil, err
 	}
-	return s.probeRealityTarget(host, port, xver), nil
+	sni = strings.TrimSpace(sni)
+	if sni == "" {
+		sni = host
+	} else if sni, err = netsafe.NormalizeHost(sni); err != nil {
+		return nil, common.NewError("invalid SNI: ", err)
+	}
+	return s.probeRealityAddr(host, port, sni, realityScanTimeout, xver, allowPrivate), nil
 }
 
 func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
@@ -347,7 +377,9 @@ func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanRes
 		go func(idx int, tk realityProbeTask) {
 			defer wg.Done()
 			defer func() { <-sem }()
-			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0)
+			// The bulk/CIDR scanner never reaches private ranges: the opt-in
+			// there would turn it into an internal network scanner.
+			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0, false)
 			if tk.bulk && r.TLSVersion == "" {
 				return
 			}

+ 2 - 2
internal/web/service/reality_scan_test.go

@@ -78,13 +78,13 @@ func TestSplitRealityTarget(t *testing.T) {
 }
 
 func TestScanRealityTargetInputValidation(t *testing.T) {
-	if _, err := (&ServerService{}).ScanRealityTarget("", 0); err == nil {
+	if _, err := (&ServerService{}).ScanRealityTarget("", "", 0, false); err == nil {
 		t.Error("ScanRealityTarget(empty) expected error, got nil")
 	}
 }
 
 func TestScanRealityTargetBlocksPrivate(t *testing.T) {
-	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", 0)
+	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", "", 0, false)
 	if err != nil {
 		t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
 	}

+ 19 - 7
internal/web/service/server.go

@@ -1488,25 +1488,37 @@ func restoreHostBoundSettings(snap hostBoundSnapshot) {
 	if db == nil {
 		return
 	}
+	settingSvc := &SettingService{}
 	for _, key := range hostBoundSettingKeys {
 		if _, had := snap.present[key]; !had {
-			// No row here before the import, so the default applied. Drop the
-			// imported row rather than inherit the source machine's value.
+			// Absent because it is minted on demand, not because a default applied:
+			// the imported copy is the only one that exists, so keep it (#6227).
+			if lazilyMintedSettingKeys[key] {
+				continue
+			}
 			if err := db.Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil {
 				logger.Warningf("Import: could not drop imported setting %q: %v", key, err)
 			}
 			continue
 		}
-		// The imported row may or may not exist; settings are key-value, so an
-		// upsert keyed on the name is the only safe write here.
-		if err := db.Where(model.Setting{Key: key}).
-			Assign(model.Setting{Value: snap.values[key]}).
-			FirstOrCreate(&model.Setting{}).Error; err != nil {
+		// saveSetting rather than Assign(struct): GORM drops zero-valued fields from
+		// the assignment map, so an empty local value never overwrote the import.
+		if err := settingSvc.saveSetting(key, snap.values[key]); err != nil {
 			logger.Warningf("Import: could not restore setting %q for this machine: %v", key, err)
 		}
 	}
 }
 
+// Minted on demand, so a fresh install has no row: dropping the imported copy
+// would destroy the only one that exists, CA private key included.
+var lazilyMintedSettingKeys = map[string]bool{
+	"nodeMtlsCaCertPem":     true,
+	"nodeMtlsCaKeyPem":      true,
+	"nodeMtlsClientCertPem": true,
+	"nodeMtlsClientKeyPem":  true,
+	"nodeMtlsClientCAPem":   true,
+}
+
 func (s *ServerService) ImportDB(file multipart.File, keepHostSettings bool) error {
 	if database.IsPostgres() {
 		return s.importPostgresDB(file, keepHostSettings)

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

@@ -64,6 +64,7 @@ var defaultValueMap = map[string]string{
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"trustedProxyCIDRs":           DefaultTrustedProxyCIDRs,
+	"ipLimitAllowlist":            "",
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
@@ -650,6 +651,12 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
 	return s.getInt("sessionMaxAge")
 }
 
+// GetIpLimitAllowlist returns the operator's trusted addresses and networks,
+// which the IP limit neither counts nor bans.
+func (s *SettingService) GetIpLimitAllowlist() (string, error) {
+	return s.getString("ipLimitAllowlist")
+}
+
 func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
 	return s.getString("trustedProxyCIDRs")
 }
@@ -1320,6 +1327,26 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
 	// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
 	allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
 	allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
+	for name, value := range map[string]*string{
+		"Happ routing source":         &allSetting.SubRoutingRules,
+		"Clash/Mihomo routing source": &allSetting.SubClashRules,
+		"Incy routing source":         &allSetting.SubIncyRoutingRules,
+	} {
+		if err := validateRemoteRoutingURLSetting(name, value); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func validateRemoteRoutingURLSetting(name string, value *string) error {
+	canonical, remote, err := common.ParseRemoteRoutingURL(*value)
+	if err != nil {
+		return common.NewError(name, err.Error())
+	}
+	if remote {
+		*value = canonical
+	}
 	return nil
 }
 

+ 38 - 0
internal/web/service/setting_remote_routing_test.go

@@ -0,0 +1,38 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+)
+
+func TestValidateRemoteRoutingURLSettings(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		want      string
+		wantError string
+	}{
+		{name: "valid HTTPS", value: " https://example.com/rules#fragment ", want: "https://example.com/rules"},
+		{name: "credentials", value: "https://user:[email protected]/rules", wantError: "must not contain URL credentials"},
+		{name: "missing host", value: "https:///rules", wantError: "absolute HTTPS URL"},
+		{name: "legacy HTTP stays inline", value: "http://example.com/rules", want: "http://example.com/rules"},
+		{name: "multiline Clash stays inline", value: "https://example.com/rules\nMATCH,PROXY", want: "https://example.com/rules\nMATCH,PROXY"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			settings := &entity.AllSetting{SubRoutingRules: tt.value}
+			err := validateSettingsURLs(settings)
+			if tt.wantError != "" {
+				if err == nil || !strings.Contains(err.Error(), tt.wantError) {
+					t.Fatalf("err=%v, want %q", err, tt.wantError)
+				}
+				return
+			}
+			if err != nil || settings.SubRoutingRules != tt.want {
+				t.Fatalf("value=%q err=%v", settings.SubRoutingRules, err)
+			}
+		})
+	}
+}

+ 21 - 6
internal/web/translation/ar-EG.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "الاحتفاظ بإعدادات هذا الجهاز",
+      "importKeepHostSettingsDesc": "يحتفظ بعناوين الاستماع والمنافذ والمسار الأساسي والشهادات وهوية العقدة الخاصة بهذه اللوحة بدلًا من أخذها من الملف المرفوع."
     },
     "inbounds": {
       "totalDownUp": "إجمالي المرسل/المستقبل",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "فشل فحص هدف REALITY.",
         "scanRealityTargetFeasible": "الهدف مناسب — تم ملء الهدف وSNI.",
         "scanRealityTargetNotFeasible": "الهدف قابل للوصول لكنه غير مناسب لـ REALITY.",
+        "scanRealityTargetPrivate": "الهدف يعمل لكنه في شبكة خاصة/محلية.",
         "invalidClientField": "العميل {client}: الحقل {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} أخرى)"
@@ -612,6 +613,11 @@
         "scanCurve": "تبادل المفاتيح",
         "scanCert": "الشهادة",
         "scanCertInvalid": "غير موثوق",
+        "scanCertExpiry": "انتهاء صلاحية الشهادة",
+        "scanSniUsed": "SNI المستخدم",
+        "scanPrivateNote": "تم الفحص عبر شبكة خاصة/محلية — هذا العنوان غير قابل للوصول من الإنترنت.",
+        "scanPrivateConfirmTitle": "الهدف في شبكة محلية",
+        "scanPrivateConfirmContent": "يشير \"{target}\" إلى عنوان خاص أو محلي. سيتجاوز الفحص حماية SSRF في اللوحة لهذا الاختبار فقط. هل تريد المتابعة؟",
         "scanLatency": "زمن الاستجابة",
         "scanUse": "استخدام",
         "scanRescan": "إعادة الفحص",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "إضافة اشتراك خارجي",
       "noExternalLinks": "لا توجد روابط خارجية بعد.",
       "noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
+      "namePrefix": "بادئة الاسم",
+      "lastFetchAt": "آخر جلب",
+      "lastFetchError": "خطأ في الجلب",
+      "neverFetched": "لم يتم الجلب بعد",
       "submitEdit": "حفظ التغييرات",
       "clientCount": "عدد العملاء",
       "bulk": "إضافة مجمعة",
@@ -874,6 +884,8 @@
       },
       "renewMax": "الحد الأقصى للتجديدات",
       "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.",
+      "renewOnDay": "يوم التجديد",
+      "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
       "renewsUsed": "التجديدات المستخدمة"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "تفعيل التوجيه",
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
-      "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
+      "subRoutingRulesDesc": "ألصق رابط happ:// جاهزًا أو عنوان HTTPS دائمًا. تحدّث اللوحة القواعد البعيدة في الخلفية وتحتفظ بآخر قيمة صالحة، لذلك لا تنتظر طلبات الاشتراك المصدر. (فقط لـ Happ)",
       "subHideSettings": "إخفاء إعدادات الخادم",
       "subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
       "subIncyEnableRouting": "تفعيل التوجيه",
       "subIncyEnableRoutingDesc": "حقن ملف تعريف التوجيه في محتوى الاشتراك لعميل Incy. (فقط لـ Incy)",
       "subIncyRoutingRules": "قواعد التوجيه",
-      "subIncyRoutingRulesDesc": "رابط توجيه Incy المُضاف إلى محتوى الاشتراك، مثل incy://routing/onadd/<base64>. (فقط لـ Incy)",
+      "subIncyRoutingRulesDesc": "ألصق رابط incy:// جاهزًا أو عنوان HTTPS دائمًا لملف JSON. ينشئ Incy ملف autorouting ويحدّثه تلقائيًا. (فقط لـ Incy)",
       "subClashEnableRouting": "تفعيل التوجيه",
       "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
       "subClashRoutingRules": "قواعد التوجيه العامة",
-      "subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "ألصق قواعد/YAML أو عنوان HTTPS دائمًا. تحدّثه اللوحة في الخلفية، وتستورد المجموعات وموفري القواعد والقواعد فقط، وتحافظ على عقد VPN المُنشأة وآخر قيمة صالحة.",
       "subListen": "IP الاستماع",
       "subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
       "subPort": "بورت الاستماع",
@@ -1368,7 +1380,9 @@
       "secretClear": "مسح",
       "secretClearUndo": "تراجع عن المسح",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "قائمة سماح حد IP",
+      "ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل."
     },
     "xray": {
       "save": "احفظ",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "الانتهاء كطابع زمني Unix (بالثواني)",
         "descCREATED_UNIX": "وقت الإنشاء كطابع زمني Unix (بالثواني)",
         "descRESET_DAYS": "فترة إعادة تعيين حركة المرور بالأيام",
+        "descRESET_DAY": "يوم الشهر الذي يتم فيه التجديد",
         "descPROTOCOL": "بروتوكول الوارد (VLESS، VMess، Trojan، …)",
         "descTRANSPORT": "شبكة النقل (tcp، ws، grpc، …)",
         "descSECURITY": "أمان النقل (TLS، REALITY، NONE)"

+ 19 - 4
internal/web/translation/en-US.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Failed to scan REALITY target.",
         "scanRealityTargetFeasible": "Target is feasible — filled target and SNI.",
         "scanRealityTargetNotFeasible": "Target is reachable but not feasible for REALITY.",
+        "scanRealityTargetPrivate": "Target is reachable but sits on a private/local network.",
         "invalidClientField": "Client {client}: {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} more)"
@@ -624,6 +625,11 @@
         "scanCurve": "Key Exchange",
         "scanCert": "Certificate",
         "scanCertInvalid": "Not trusted",
+        "scanCertExpiry": "Certificate expires",
+        "scanSniUsed": "SNI used",
+        "scanPrivateNote": "Checked over a private/local network — this address is not reachable from the internet.",
+        "scanPrivateConfirmTitle": "Target on a local network",
+        "scanPrivateConfirmContent": "\"{target}\" resolves to a private or loopback address. The check will bypass the panel SSRF guard for this probe only. Continue?",
         "scanLatency": "Latency",
         "scanUse": "Use",
         "scanRescan": "Rescan",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Add External Subscription",
       "noExternalLinks": "No external links yet.",
       "noExternalSubscriptions": "No external subscriptions yet.",
+      "namePrefix": "Name prefix",
+      "lastFetchAt": "Last fetch",
+      "lastFetchError": "Fetch error",
+      "neverFetched": "Not fetched yet",
       "submitEdit": "Save Changes",
       "clientCount": "Number of Clients",
       "bulk": "Add Bulk",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Max renewals",
       "renewMaxDesc": "How many times auto-renew may fire before the client is left to expire. 0 means no limit. Catching up several missed periods spends one renewal per period.",
+      "renewOnDay": "Renew on day",
+      "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
       "renewsUsed": "Renewals used"
     },
     "groups": {
@@ -1020,6 +1032,7 @@
         "descEXPIRE_UNIX": "Expiry as a Unix timestamp (seconds)",
         "descCREATED_UNIX": "Creation time as a Unix timestamp (seconds)",
         "descRESET_DAYS": "Traffic reset period in days",
+        "descRESET_DAY": "Calendar renewal day of the month",
         "descPROTOCOL": "Inbound protocol (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Transport network (tcp, ws, grpc, …)",
         "descSECURITY": "Transport security (TLS, REALITY, NONE)"
@@ -1267,17 +1280,17 @@
       "subEnableRouting": "Enable routing",
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
-      "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
+      "subRoutingRulesDesc": "Paste a ready happ:// deeplink or one permanent HTTPS URL returning a deeplink or JSON. The panel refreshes remote rules in the background and keeps the last valid value, so subscription requests never wait for the source. (Happ only)",
       "subHideSettings": "Hide server settings",
       "subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
       "subIncyEnableRouting": "Enable routing",
       "subIncyEnableRoutingDesc": "Inject a routing profile into the subscription body for the Incy client. (Only for Incy)",
       "subIncyRoutingRules": "Routing rules",
-      "subIncyRoutingRulesDesc": "Incy routing deep-link added to the subscription body, e.g. incy://routing/onadd/<base64>. (Only for Incy)",
+      "subIncyRoutingRulesDesc": "Paste a ready incy:// deeplink or one permanent HTTPS URL returning JSON. An HTTPS URL becomes an autorouting profile refreshed by Incy itself. (Incy only)",
       "subClashEnableRouting": "Enable routing",
       "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
       "subClashRoutingRules": "Global routing rules",
-      "subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Paste inline rules/YAML or one permanent HTTPS URL. The panel refreshes it in the background, imports only groups/providers/rules, preserves generated VPN nodes and keeps the last valid value.",
       "subListen": "Listen IP",
       "subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
       "subPort": "Listen Port",
@@ -1485,7 +1498,9 @@
       "secretClear": "Clear",
       "secretClearUndo": "Undo clear",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP limit allowlist",
+      "ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR."
     },
     "xray": {
       "save": "Save",

+ 21 - 6
internal/web/translation/es-ES.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "Mantener la configuración de esta máquina",
+      "importKeepHostSettingsDesc": "Conserva las direcciones de escucha, los puertos, la ruta base, los certificados y la identidad de nodo de este panel en lugar de tomarlos del archivo subido."
     },
     "inbounds": {
       "totalDownUp": "Subidas/Descargas Totales",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "No se pudo escanear el objetivo REALITY.",
         "scanRealityTargetFeasible": "El objetivo es apto: se rellenaron el objetivo y el SNI.",
         "scanRealityTargetNotFeasible": "El objetivo es accesible pero no apto para REALITY.",
+        "scanRealityTargetPrivate": "El destino funciona, pero está en una red privada/local.",
         "invalidClientField": "Cliente {client}: campo {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} más)"
@@ -633,6 +634,11 @@
         "scanCurve": "Intercambio de claves",
         "scanCert": "Certificado",
         "scanCertInvalid": "No confiable",
+        "scanCertExpiry": "El certificado caduca",
+        "scanSniUsed": "SNI utilizado",
+        "scanPrivateNote": "Comprobado en una red privada/local: esta dirección no es accesible desde internet.",
+        "scanPrivateConfirmTitle": "Destino en una red local",
+        "scanPrivateConfirmContent": "\"{target}\" apunta a una dirección privada o de loopback. La comprobación omitirá la protección SSRF del panel solo para esta prueba. ¿Continuar?",
         "scanLatency": "Latencia",
         "scanUse": "Usar",
         "scanRescan": "Reescanear",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Añadir suscripción externa",
       "noExternalLinks": "Aún no hay enlaces externos.",
       "noExternalSubscriptions": "Aún no hay suscripciones externas.",
+      "namePrefix": "Prefijo de nombre",
+      "lastFetchAt": "Última obtención",
+      "lastFetchError": "Error de obtención",
+      "neverFetched": "Aún no obtenido",
       "submitEdit": "Guardar cambios",
       "clientCount": "Número de clientes",
       "bulk": "Añadir en lote",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Renovaciones máximas",
       "renewMaxDesc": "Cuántas veces puede activarse la renovación automática antes de dejar que el cliente caduque. 0 significa sin límite. Recuperar varios periodos perdidos consume una renovación por periodo.",
+      "renewOnDay": "Renovar el día",
+      "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
       "renewsUsed": "Renovaciones usadas"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Habilitar enrutamiento",
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
-      "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
+      "subRoutingRulesDesc": "Pegue un enlace happ:// listo o una URL HTTPS permanente. El panel actualiza las reglas remotas en segundo plano y conserva el último valor válido, sin retrasar las solicitudes de suscripción. (Solo para Happ)",
       "subHideSettings": "Ocultar configuración del servidor",
       "subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
       "subIncyEnableRouting": "Habilitar enrutamiento",
       "subIncyEnableRoutingDesc": "Inyectar un perfil de enrutamiento en el cuerpo de la suscripción para el cliente Incy. (Solo para Incy)",
       "subIncyRoutingRules": "Reglas de enrutamiento",
-      "subIncyRoutingRulesDesc": "Enlace de enrutamiento de Incy añadido al cuerpo de la suscripción, p. ej. incy://routing/onadd/<base64>. (Solo para Incy)",
+      "subIncyRoutingRulesDesc": "Pegue un enlace incy:// listo o una URL HTTPS permanente a JSON. Incy crea un perfil de autorouting y lo actualiza automáticamente. (Solo para Incy)",
       "subClashEnableRouting": "Habilitar enrutamiento",
       "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
       "subClashRoutingRules": "Reglas globales de enrutamiento",
-      "subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Pegue reglas/YAML o una URL HTTPS permanente. El panel la actualiza en segundo plano, importa solo grupos, proveedores de reglas y reglas, y conserva los nodos VPN generados y el último valor válido.",
       "subListen": "Listening IP",
       "subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
       "subPort": "Puerto de Suscripción",
@@ -1368,7 +1380,9 @@
       "secretClear": "Borrar",
       "secretClearUndo": "Deshacer borrado",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Lista de permitidos del límite de IP",
+      "ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma."
     },
     "xray": {
       "save": "Guardar configuración",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Expiración como marca de tiempo Unix (segundos)",
         "descCREATED_UNIX": "Hora de creación como marca de tiempo Unix (segundos)",
         "descRESET_DAYS": "Periodo de reinicio de tráfico en días",
+        "descRESET_DAY": "Día del mes en que se renueva",
         "descPROTOCOL": "Protocolo del inbound (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Red de transporte (tcp, ws, grpc, …)",
         "descSECURITY": "Seguridad del transporte (TLS, REALITY, NONE)"

+ 21 - 6
internal/web/translation/fa-IR.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "حفظ تنظیمات این دستگاه",
+      "importKeepHostSettingsDesc": "آدرس‌ها و پورت‌های شنود، مسیر پایه، گواهی‌ها و هویت نودِ همین پنل را نگه می‌دارد و آن‌ها را از فایل بارگذاری‌شده نمی‌گیرد."
     },
     "inbounds": {
       "totalDownUp": "دریافت/ارسال کل",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "اسکن هدف REALITY ناموفق بود.",
         "scanRealityTargetFeasible": "هدف مناسب است — هدف و SNI پر شد.",
         "scanRealityTargetNotFeasible": "هدف در دسترس است اما برای REALITY مناسب نیست.",
+        "scanRealityTargetPrivate": "هدف کار می‌کند اما در شبکهٔ خصوصی/محلی قرار دارد.",
         "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} مورد دیگر)"
@@ -624,6 +625,11 @@
         "scanCurve": "تبادل کلید",
         "scanCert": "گواهی",
         "scanCertInvalid": "نامعتبر",
+        "scanCertExpiry": "انقضای گواهی",
+        "scanSniUsed": "SNI استفاده‌شده",
+        "scanPrivateNote": "بررسی از طریق شبکهٔ خصوصی/محلی انجام شد — این نشانی از اینترنت قابل دسترسی نیست.",
+        "scanPrivateConfirmTitle": "هدف در شبکهٔ محلی",
+        "scanPrivateConfirmContent": "«{target}» به یک نشانی خصوصی یا loopback اشاره می‌کند. بررسی تنها برای همین کاوش، محافظت SSRF پنل را نادیده می‌گیرد. ادامه می‌دهید؟",
         "scanLatency": "تأخیر",
         "scanUse": "استفاده",
         "scanRescan": "اسکن مجدد",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "افزودن سابسکریپشن خارجی",
       "noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.",
       "noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.",
+      "namePrefix": "پیشوند نام",
+      "lastFetchAt": "آخرین دریافت",
+      "lastFetchError": "خطای دریافت",
+      "neverFetched": "هنوز دریافت نشده",
       "submitEdit": "ذخیره تغییرات",
       "clientCount": "تعداد کلاینت‌ها",
       "bulk": "افزودن گروهی",
@@ -874,6 +884,8 @@
       },
       "renewMax": "حداکثر تعداد تمدید",
       "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.",
+      "renewOnDay": "روز تمدید",
+      "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
       "renewsUsed": "تمدیدهای استفاده‌شده"
     },
     "groups": {
@@ -1150,17 +1162,17 @@
       "subEnableRouting": "فعال‌سازی مسیریابی",
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
-      "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
+      "subRoutingRulesDesc": "یک پیوند آماده happ:// یا یک نشانی دائمی HTTPS وارد کنید. پنل قوانین راه‌دور را در پس‌زمینه به‌روزرسانی و آخرین مقدار معتبر را نگه می‌دارد، بنابراین درخواست اشتراک منتظر منبع نمی‌ماند. (فقط برای Happ)",
       "subHideSettings": "پنهان کردن تنظیمات سرور",
       "subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
       "subIncyEnableRouting": "فعال‌سازی مسیریابی",
       "subIncyEnableRoutingDesc": "تزریق پروفایل مسیریابی به بدنه اشتراک برای کلاینت Incy. (فقط برای Incy)",
       "subIncyRoutingRules": "قوانین مسیریابی",
-      "subIncyRoutingRulesDesc": "لینک مسیریابی Incy که به بدنه اشتراک افزوده می‌شود، مثلاً incy://routing/onadd/<base64>. (فقط برای Incy)",
+      "subIncyRoutingRulesDesc": "یک پیوند آماده incy:// یا یک نشانی دائمی HTTPS برای JSON وارد کنید. Incy یک نمایه autorouting می‌سازد و آن را خودکار به‌روزرسانی می‌کند. (فقط برای Incy)",
       "subClashEnableRouting": "فعال‌سازی مسیریابی",
       "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
       "subClashRoutingRules": "قوانین مسیریابی سراسری",
-      "subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده می‌شوند.",
+      "subClashRoutingRulesDesc": "قوانین/YAML یا یک نشانی دائمی HTTPS وارد کنید. پنل آن را در پس‌زمینه به‌روزرسانی می‌کند، فقط گروه‌ها، ارائه‌دهندگان قانون و قوانین را وارد می‌کند و گره‌های VPN ساخته‌شده و آخرین مقدار معتبر را حفظ می‌کند.",
       "subListen": "آدرس آی‌پی",
       "subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
       "subPort": "پورت",
@@ -1368,7 +1380,9 @@
       "secretClear": "پاک کردن",
       "secretClearUndo": "لغو پاک کردن",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "فهرست مجاز محدودیت IP",
+      "ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما)."
     },
     "xray": {
       "save": "ذخیره",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "انقضا به‌صورت مهر زمانی Unix (ثانیه)",
         "descCREATED_UNIX": "زمان ایجاد به‌صورت مهر زمانی Unix (ثانیه)",
         "descRESET_DAYS": "دورهٔ بازنشانی ترافیک به روز",
+        "descRESET_DAY": "روز ماه برای تمدید تقویمی",
         "descPROTOCOL": "پروتکل اینباند (VLESS، VMess، Trojan، …)",
         "descTRANSPORT": "شبکهٔ انتقال (tcp، ws، grpc، …)",
         "descSECURITY": "امنیت انتقال (TLS، REALITY، NONE)"

+ 21 - 6
internal/web/translation/id-ID.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "Pertahankan pengaturan mesin ini",
+      "importKeepHostSettingsDesc": "Mempertahankan alamat dengar, port, path dasar, sertifikat, dan identitas node panel ini alih-alih mengambilnya dari berkas yang diunggah."
     },
     "inbounds": {
       "totalDownUp": "Total Terkirim/Diterima",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Gagal memindai target REALITY.",
         "scanRealityTargetFeasible": "Target layak — target dan SNI terisi.",
         "scanRealityTargetNotFeasible": "Target dapat dijangkau tetapi tidak layak untuk REALITY.",
+        "scanRealityTargetPrivate": "Target dapat dijangkau, tetapi berada di jaringan privat/lokal.",
         "invalidClientField": "Klien {client}: kolom {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} lainnya)"
@@ -612,6 +613,11 @@
         "scanCurve": "Pertukaran Kunci",
         "scanCert": "Sertifikat",
         "scanCertInvalid": "Tidak tepercaya",
+        "scanCertExpiry": "Sertifikat kedaluwarsa",
+        "scanSniUsed": "SNI yang dipakai",
+        "scanPrivateNote": "Diperiksa melalui jaringan privat/lokal — alamat ini tidak dapat dijangkau dari internet.",
+        "scanPrivateConfirmTitle": "Target di jaringan lokal",
+        "scanPrivateConfirmContent": "\"{target}\" mengarah ke alamat privat atau loopback. Pemeriksaan akan melewati pelindung SSRF panel hanya untuk uji ini. Lanjutkan?",
         "scanLatency": "Latensi",
         "scanUse": "Gunakan",
         "scanRescan": "Pindai ulang",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Tambah Langganan Eksternal",
       "noExternalLinks": "Belum ada tautan eksternal.",
       "noExternalSubscriptions": "Belum ada langganan eksternal.",
+      "namePrefix": "Awalan nama",
+      "lastFetchAt": "Pengambilan terakhir",
+      "lastFetchError": "Galat pengambilan",
+      "neverFetched": "Belum diambil",
       "submitEdit": "Simpan perubahan",
       "clientCount": "Jumlah klien",
       "bulk": "Tambah massal",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Maksimum perpanjangan",
       "renewMaxDesc": "Berapa kali perpanjangan otomatis boleh berjalan sebelum klien dibiarkan kedaluwarsa. 0 berarti tanpa batas. Mengejar beberapa periode yang terlewat menghabiskan satu perpanjangan per periode.",
+      "renewOnDay": "Perpanjang pada tanggal",
+      "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
       "renewsUsed": "Perpanjangan terpakai"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Aktifkan perutean",
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
-      "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
+      "subRoutingRulesDesc": "Tempel deeplink happ:// siap pakai atau satu URL HTTPS permanen. Panel memperbarui aturan jarak jauh di latar belakang dan menyimpan nilai valid terakhir, sehingga permintaan langganan tidak menunggu sumber. (Hanya untuk Happ)",
       "subHideSettings": "Sembunyikan pengaturan server",
       "subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
       "subIncyEnableRouting": "Aktifkan perutean",
       "subIncyEnableRoutingDesc": "Menyuntikkan profil perutean ke dalam body langganan untuk klien Incy. (Hanya untuk Incy)",
       "subIncyRoutingRules": "Aturan routing",
-      "subIncyRoutingRulesDesc": "Tautan perutean Incy yang ditambahkan ke body langganan, mis. incy://routing/onadd/<base64>. (Hanya untuk Incy)",
+      "subIncyRoutingRulesDesc": "Tempel deeplink incy:// siap pakai atau URL HTTPS permanen ke JSON. Incy membuat profil autorouting dan memperbaruinya secara otomatis. (Hanya untuk Incy)",
       "subClashEnableRouting": "Aktifkan routing",
       "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
       "subClashRoutingRules": "Aturan routing global",
-      "subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Tempel aturan/YAML atau satu URL HTTPS permanen. Panel memperbaruinya di latar belakang, hanya mengimpor grup, penyedia aturan, dan aturan, serta mempertahankan node VPN buatan panel dan nilai valid terakhir.",
       "subListen": "IP Pendengar",
       "subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
       "subPort": "Port Pendengar",
@@ -1368,7 +1380,9 @@
       "secretClear": "Hapus",
       "secretClearUndo": "Batalkan hapus",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Daftar izin batas IP",
+      "ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma)."
     },
     "xray": {
       "save": "Simpan",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Kedaluwarsa sebagai timestamp Unix (detik)",
         "descCREATED_UNIX": "Waktu pembuatan sebagai timestamp Unix (detik)",
         "descRESET_DAYS": "Periode reset trafik dalam hari",
+        "descRESET_DAY": "Tanggal perpanjangan setiap bulan",
         "descPROTOCOL": "Protokol inbound (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Jaringan transport (tcp, ws, grpc, …)",
         "descSECURITY": "Keamanan transport (TLS, REALITY, NONE)"

+ 21 - 6
internal/web/translation/ja-JP.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "このマシンの設定を保持する",
+      "importKeepHostSettingsDesc": "アップロードしたファイルの値ではなく、このパネルのリッスンアドレス、ポート、ベースパス、証明書、ノード ID を保持します。"
     },
     "inbounds": {
       "totalDownUp": "総アップロード / ダウンロード",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "REALITY ターゲットのスキャンに失敗しました。",
         "scanRealityTargetFeasible": "ターゲットは利用可能です — ターゲットと SNI を入力しました。",
         "scanRealityTargetNotFeasible": "ターゲットには到達できますが、REALITY には利用できません。",
+        "scanRealityTargetPrivate": "ターゲットは利用可能ですが、プライベート/ローカルネットワーク上にあります。",
         "invalidClientField": "クライアント {client}: フィールド {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (他 {count} 件)"
@@ -633,6 +634,11 @@
         "scanCurve": "鍵交換",
         "scanCert": "証明書",
         "scanCertInvalid": "信頼できません",
+        "scanCertExpiry": "証明書の有効期限",
+        "scanSniUsed": "使用した SNI",
+        "scanPrivateNote": "プライベート/ローカルネットワーク経由で確認しました。このアドレスはインターネットからは到達できません。",
+        "scanPrivateConfirmTitle": "ローカルネットワーク上のターゲット",
+        "scanPrivateConfirmContent": "「{target}」はプライベートまたはループバックアドレスに解決されます。このプローブに限りパネルの SSRF 保護をバイパスします。続行しますか?",
         "scanLatency": "レイテンシ",
         "scanUse": "使用",
         "scanRescan": "再スキャン",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "外部サブスクリプションを追加",
       "noExternalLinks": "外部リンクはまだありません。",
       "noExternalSubscriptions": "外部サブスクリプションはまだありません。",
+      "namePrefix": "名前の接頭辞",
+      "lastFetchAt": "最終取得",
+      "lastFetchError": "取得エラー",
+      "neverFetched": "未取得",
       "submitEdit": "変更を保存",
       "clientCount": "クライアント数",
       "bulk": "一括追加",
@@ -874,6 +884,8 @@
       },
       "renewMax": "最大更新回数",
       "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。",
+      "renewOnDay": "更新する日",
+      "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
       "renewsUsed": "使用済み更新回数"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "ルーティングを有効化",
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
-      "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
+      "subRoutingRulesDesc": "完成した happ:// ディープリンク、または永続的な HTTPS URL を入力します。パネルはリモートルールをバックグラウンドで更新し、最後の有効値を保持するため、サブスクリプション要求は取得を待ちません。(Happのみ)",
       "subHideSettings": "サーバー設定を非表示",
       "subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
       "subIncyEnableRouting": "ルーティングを有効化",
       "subIncyEnableRoutingDesc": "Incyクライアント用に、サブスクリプション本文へルーティングプロファイルを挿入します。(Incyのみ)",
       "subIncyRoutingRules": "ルーティングルール",
-      "subIncyRoutingRulesDesc": "サブスクリプション本文に追加するIncyルーティングのディープリンク。例: incy://routing/onadd/<base64>。(Incyのみ)",
+      "subIncyRoutingRulesDesc": "完成した incy:// ディープリンク、または JSON への永続的な HTTPS URL を入力します。Incy は autorouting プロファイルを作成し、自動更新します。(Incyのみ)",
       "subClashEnableRouting": "ルーティングを有効化",
       "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
       "subClashRoutingRules": "グローバルルーティングルール",
-      "subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
+      "subClashRoutingRulesDesc": "ルール/YAML、または永続的な HTTPS URL を入力します。パネルはバックグラウンドで更新し、グループ・ルールプロバイダー・ルールのみを取り込み、生成済み VPN ノードと最後の有効値を保持します。",
       "subListen": "監視IP",
       "subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
       "subPort": "監視ポート",
@@ -1368,7 +1380,9 @@
       "secretClear": "クリア",
       "secretClearUndo": "クリアを取り消す",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP 制限の許可リスト",
+      "ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。"
     },
     "xray": {
       "importRules": "ルールをインポート",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "有効期限の Unix タイムスタンプ(秒)",
         "descCREATED_UNIX": "作成時刻の Unix タイムスタンプ(秒)",
         "descRESET_DAYS": "トラフィックリセット周期(日数)",
+        "descRESET_DAY": "毎月の更新日",
         "descPROTOCOL": "インバウンドのプロトコル(VLESS、VMess、Trojan など)",
         "descTRANSPORT": "トランスポートネットワーク(tcp、ws、grpc など)",
         "descSECURITY": "トランスポートのセキュリティ(TLS、REALITY、NONE)"

+ 21 - 6
internal/web/translation/pt-BR.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "Manter as configurações desta máquina",
+      "importKeepHostSettingsDesc": "Mantém os endereços de escuta, as portas, o caminho base, os certificados e a identidade de nó deste painel em vez de obtê-los do arquivo enviado."
     },
     "inbounds": {
       "totalDownUp": "Total Enviado/Recebido",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Falha ao escanear o alvo REALITY.",
         "scanRealityTargetFeasible": "O alvo é viável — alvo e SNI preenchidos.",
         "scanRealityTargetNotFeasible": "O alvo é acessível, mas não é viável para REALITY.",
+        "scanRealityTargetPrivate": "O destino funciona, mas está em uma rede privada/local.",
         "invalidClientField": "Cliente {client}: campo {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} mais)"
@@ -633,6 +634,11 @@
         "scanCurve": "Troca de chaves",
         "scanCert": "Certificado",
         "scanCertInvalid": "Não confiável",
+        "scanCertExpiry": "Certificado expira",
+        "scanSniUsed": "SNI utilizado",
+        "scanPrivateNote": "Verificado em uma rede privada/local — este endereço não é acessível pela internet.",
+        "scanPrivateConfirmTitle": "Destino em uma rede local",
+        "scanPrivateConfirmContent": "\"{target}\" resolve para um endereço privado ou de loopback. A verificação ignorará a proteção SSRF do painel apenas nesta sondagem. Continuar?",
         "scanLatency": "Latência",
         "scanUse": "Usar",
         "scanRescan": "Reescanear",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Adicionar assinatura externa",
       "noExternalLinks": "Ainda não há links externos.",
       "noExternalSubscriptions": "Ainda não há assinaturas externas.",
+      "namePrefix": "Prefixo do nome",
+      "lastFetchAt": "Última busca",
+      "lastFetchError": "Erro na busca",
+      "neverFetched": "Ainda não buscado",
       "submitEdit": "Salvar alterações",
       "clientCount": "Número de clientes",
       "bulk": "Adicionar em lote",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Renovações máximas",
       "renewMaxDesc": "Quantas vezes a renovação automática pode ocorrer antes de o cliente ser deixado a expirar. 0 significa sem limite. Recuperar vários períodos perdidos consome uma renovação por período.",
+      "renewOnDay": "Renovar no dia",
+      "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
       "renewsUsed": "Renovações usadas"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Ativar roteamento",
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
-      "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
+      "subRoutingRulesDesc": "Cole um deeplink happ:// pronto ou uma URL HTTPS permanente. O painel atualiza as regras remotas em segundo plano e mantém o último valor válido, sem atrasar as solicitações de assinatura. (Apenas para Happ)",
       "subHideSettings": "Ocultar configurações do servidor",
       "subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
       "subIncyEnableRouting": "Ativar roteamento",
       "subIncyEnableRoutingDesc": "Injetar um perfil de roteamento no corpo da assinatura para o cliente Incy. (Apenas para Incy)",
       "subIncyRoutingRules": "Regras de roteamento",
-      "subIncyRoutingRulesDesc": "Link de roteamento do Incy adicionado ao corpo da assinatura, ex. incy://routing/onadd/<base64>. (Apenas para Incy)",
+      "subIncyRoutingRulesDesc": "Cole um deeplink incy:// pronto ou uma URL HTTPS permanente para JSON. O Incy cria um perfil de autorouting e o atualiza automaticamente. (Apenas para Incy)",
       "subClashEnableRouting": "Ativar roteamento",
       "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
       "subClashRoutingRules": "Regras globais de roteamento",
-      "subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Cole regras/YAML ou uma URL HTTPS permanente. O painel a atualiza em segundo plano, importa apenas grupos, provedores de regras e regras, e preserva os nós VPN gerados e o último valor válido.",
       "subListen": "IP de Escuta",
       "subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
       "subPort": "Porta de Escuta",
@@ -1368,7 +1380,9 @@
       "secretClear": "Limpar",
       "secretClearUndo": "Desfazer limpeza",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Lista de permissões do limite de IP",
+      "ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula."
     },
     "xray": {
       "importRules": "Importar regras",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Expiração como timestamp Unix (segundos)",
         "descCREATED_UNIX": "Data de criação como timestamp Unix (segundos)",
         "descRESET_DAYS": "Período de redefinição de tráfego em dias",
+        "descRESET_DAY": "Dia do mês em que é renovado",
         "descPROTOCOL": "Protocolo da entrada (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Rede de transporte (tcp, ws, grpc, …)",
         "descSECURITY": "Segurança do transporte (TLS, REALITY, NONE)"

+ 19 - 4
internal/web/translation/ru-RU.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Не удалось просканировать цель REALITY.",
         "scanRealityTargetFeasible": "Цель подходит — поля target и SNI заполнены.",
         "scanRealityTargetNotFeasible": "Цель доступна, но не подходит для REALITY.",
+        "scanRealityTargetPrivate": "Цель работает, но находится в приватной (локальной) сети.",
         "invalidClientField": "Клиент {client}: поле {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} ещё)"
@@ -633,6 +634,11 @@
         "scanCurve": "Обмен ключами",
         "scanCert": "Сертификат",
         "scanCertInvalid": "Не доверенный",
+        "scanCertExpiry": "Сертификат истекает",
+        "scanSniUsed": "Использованный SNI",
+        "scanPrivateNote": "Проверено во внутренней (локальной) сети — этот адрес недоступен из интернета.",
+        "scanPrivateConfirmTitle": "Цель в локальной сети",
+        "scanPrivateConfirmContent": "«{target}» указывает на приватный или локальный адрес. Проверка обойдёт SSRF-защиту панели только для этого запроса. Продолжить?",
         "scanLatency": "Задержка",
         "scanUse": "Выбрать",
         "scanRescan": "Пересканировать",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Добавить внешнюю подписку",
       "noExternalLinks": "Пока нет внешних ссылок.",
       "noExternalSubscriptions": "Пока нет внешних подписок.",
+      "namePrefix": "Префикс имени",
+      "lastFetchAt": "Последнее обновление",
+      "lastFetchError": "Ошибка обновления",
+      "neverFetched": "Ещё не загружено",
       "submitEdit": "Сохранить изменения",
       "clientCount": "Количество клиентов",
       "bulk": "Массовое добавление",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Лимит продлений",
       "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.",
+      "renewOnDay": "Продлевать числа",
+      "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
       "renewsUsed": "Продлений израсходовано"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Включить маршрутизацию",
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
-      "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
+      "subRoutingRulesDesc": "Вставьте готовый happ:// deeplink либо одну постоянную HTTPS-ссылку на deeplink или JSON. Панель обновляет удалённые правила в фоне и хранит последнее рабочее значение, поэтому запрос подписки не ждёт источник. (Только для Happ)",
       "subHideSettings": "Скрыть настройки сервера",
       "subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
       "subIncyEnableRouting": "Включить маршрутизацию",
       "subIncyEnableRoutingDesc": "Внедрять профиль маршрутизации в тело подписки для клиента Incy. (Только для Incy)",
       "subIncyRoutingRules": "Правила маршрутизации",
-      "subIncyRoutingRulesDesc": "Ссылка маршрутизации Incy, добавляемая в тело подписки, напр. incy://routing/onadd/<base64>. (Только для Incy)",
+      "subIncyRoutingRulesDesc": "Вставьте готовый incy:// deeplink либо одну постоянную HTTPS-ссылку на JSON. Для HTTPS-ссылки создаётся autorouting-профиль, который Incy обновляет самостоятельно. (Только для Incy)",
       "subClashEnableRouting": "Включить маршрутизацию",
       "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
       "subClashRoutingRules": "Глобальные правила маршрутизации",
-      "subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Вставьте правила/YAML либо одну постоянную HTTPS-ссылку. Панель обновляет её в фоне, импортирует только группы, провайдеры правил и правила, сохраняет созданные VPN-узлы и последнее рабочее значение.",
       "subListen": "Прослушивание IP",
       "subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
       "subPort": "Порт подписки",
@@ -1368,7 +1380,9 @@
       "secretClear": "Очистить",
       "secretClearUndo": "Отменить очистку",
       "calendarGregorian": "Григорианский (обычный)",
-      "calendarJalalian": "Джалали (شمسی)"
+      "calendarJalalian": "Джалали (شمسی)",
+      "ipLimitAllowlist": "Доверенные адреса для лимита",
+      "ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть."
     },
     "xray": {
       "importRules": "Импорт правил",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Окончание в виде Unix-метки времени (секунды)",
         "descCREATED_UNIX": "Время создания в виде Unix-метки времени (секунды)",
         "descRESET_DAYS": "Период сброса трафика в днях",
+        "descRESET_DAY": "Число месяца, в которое продлевается доступ",
         "descPROTOCOL": "Протокол входящего (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Транспортная сеть (tcp, ws, grpc, …)",
         "descSECURITY": "Безопасность транспорта (TLS, REALITY, NONE)"

+ 21 - 6
internal/web/translation/tr-TR.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "Bu makinenin ayarlarını koru",
+      "importKeepHostSettingsDesc": "Yüklenen dosyadan almak yerine bu panelin dinleme adreslerini, portlarını, temel yolunu, sertifikalarını ve düğüm kimliğini korur."
     },
     "inbounds": {
       "totalDownUp": "Toplam Gönderilen/Alınan",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "REALITY hedefi taranamadı.",
         "scanRealityTargetFeasible": "Hedef uygun — hedef ve SNI dolduruldu.",
         "scanRealityTargetNotFeasible": "Hedefe ulaşılabiliyor ancak REALITY için uygun değil.",
+        "scanRealityTargetPrivate": "Hedef çalışıyor ancak özel/yerel bir ağda.",
         "invalidClientField": "Kullanıcı {client}: {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} tane daha)"
@@ -612,6 +613,11 @@
         "scanCurve": "Anahtar Değişimi",
         "scanCert": "Sertifika",
         "scanCertInvalid": "Güvenilmez",
+        "scanCertExpiry": "Sertifika bitiş tarihi",
+        "scanSniUsed": "Kullanılan SNI",
+        "scanPrivateNote": "Özel/yerel ağ üzerinden kontrol edildi — bu adrese internetten erişilemez.",
+        "scanPrivateConfirmTitle": "Hedef yerel ağda",
+        "scanPrivateConfirmContent": "\"{target}\" özel veya loopback bir adrese çözümleniyor. Kontrol, yalnızca bu deneme için panelin SSRF korumasını atlayacak. Devam edilsin mi?",
         "scanLatency": "Gecikme",
         "scanUse": "Kullan",
         "scanRescan": "Yeniden tara",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Harici Abonelik Ekle",
       "noExternalLinks": "Henüz harici bağlantı yok.",
       "noExternalSubscriptions": "Henüz harici abonelik yok.",
+      "namePrefix": "Ad öneki",
+      "lastFetchAt": "Son çekme",
+      "lastFetchError": "Çekme hatası",
+      "neverFetched": "Henüz çekilmedi",
       "submitEdit": "Değişiklikleri Kaydet",
       "clientCount": "Kullanıcı Sayısı",
       "bulk": "Toplu Ekle",
@@ -874,6 +884,8 @@
       },
       "renewMax": "En fazla yenileme",
       "renewMaxDesc": "İstemcinin süresi dolmaya bırakılmadan önce otomatik yenilemenin kaç kez çalışabileceği. 0 sınırsız demektir. Kaçırılan birden fazla dönemi telafi etmek, dönem başına bir yenileme harcar.",
+      "renewOnDay": "Yenileme günü",
+      "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
       "renewsUsed": "Kullanılan yenileme"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Yönlendirmeyi etkinleştir",
       "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
       "subRoutingRules": "Yönlendirme kuralları",
-      "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
+      "subRoutingRulesDesc": "Hazır bir happ:// derin bağlantısı veya kalıcı bir HTTPS URL'si yapıştırın. Panel uzak kuralları arka planda yeniler ve son geçerli değeri saklar; abonelik istekleri kaynağı beklemez. (Yalnızca Happ için)",
       "subHideSettings": "Sunucu ayarlarını gizle",
       "subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
       "subIncyEnableRouting": "Yönlendirmeyi etkinleştir",
       "subIncyEnableRoutingDesc": "Incy istemcisi için abonelik gövdesine bir yönlendirme profili ekleyin. (Yalnızca Incy için)",
       "subIncyRoutingRules": "Yönlendirme kuralları",
-      "subIncyRoutingRulesDesc": "Abonelik gövdesine eklenen Incy yönlendirme bağlantısı, örn. incy://routing/onadd/<base64>. (Yalnızca Incy için)",
+      "subIncyRoutingRulesDesc": "Hazır bir incy:// derin bağlantısı veya JSON için kalıcı bir HTTPS URL'si yapıştırın. Incy bir autorouting profili oluşturur ve otomatik olarak günceller. (Yalnızca Incy için)",
       "subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
       "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
       "subClashRoutingRules": "Genel Yönlendirme Kuralları",
-      "subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen varsayılan Clash/Mihomo kuralları.",
+      "subClashRoutingRulesDesc": "Kurallar/YAML veya kalıcı bir HTTPS URL'si yapıştırın. Panel bunu arka planda yeniler, yalnızca grupları, kural sağlayıcılarını ve kuralları içe aktarır; oluşturulan VPN düğümlerini ve son geçerli değeri korur.",
       "subListen": "Dinleme IP",
       "subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
       "subPort": "Dinleme Portu",
@@ -1368,7 +1380,9 @@
       "secretClear": "Temizle",
       "secretClearUndo": "Temizlemeyi geri al",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP limiti izin listesi",
+      "ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış)."
     },
     "xray": {
       "save": "Kaydet",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Son kullanma Unix zaman damgası olarak (saniye)",
         "descCREATED_UNIX": "Oluşturulma zamanı Unix zaman damgası olarak (saniye)",
         "descRESET_DAYS": "Trafik sıfırlama periyodu (gün)",
+        "descRESET_DAY": "Takvime göre yenileme günü",
         "descPROTOCOL": "Gelen bağlantı protokolü (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Taşıma ağı (tcp, ws, grpc, …)",
         "descSECURITY": "Taşıma güvenliği (TLS, REALITY, NONE)"

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

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Не вдалося просканувати ціль REALITY.",
         "scanRealityTargetFeasible": "Ціль підходить — поля target і SNI заповнено.",
         "scanRealityTargetNotFeasible": "Ціль доступна, але не підходить для REALITY.",
+        "scanRealityTargetPrivate": "Ціль працює, але розташована у приватній (локальній) мережі.",
         "invalidClientField": "Клієнт {client}: поле {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} ще)"
@@ -612,6 +613,11 @@
         "scanCurve": "Обмін ключами",
         "scanCert": "Сертифікат",
         "scanCertInvalid": "Ненадійний",
+        "scanCertExpiry": "Сертифікат діє до",
+        "scanSniUsed": "Використаний SNI",
+        "scanPrivateNote": "Перевірено у внутрішній (локальній) мережі — ця адреса недоступна з інтернету.",
+        "scanPrivateConfirmTitle": "Ціль у локальній мережі",
+        "scanPrivateConfirmContent": "«{target}» вказує на приватну або локальну адресу. Перевірка обійде SSRF-захист панелі лише для цього запиту. Продовжити?",
         "scanLatency": "Затримка",
         "scanUse": "Обрати",
         "scanRescan": "Пересканувати",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Додати зовнішню підписку",
       "noExternalLinks": "Зовнішніх посилань ще немає.",
       "noExternalSubscriptions": "Зовнішніх підписок ще немає.",
+      "namePrefix": "Префікс імені",
+      "lastFetchAt": "Останнє оновлення",
+      "lastFetchError": "Помилка оновлення",
+      "neverFetched": "Ще не завантажено",
       "submitEdit": "Зберегти зміни",
       "clientCount": "Кількість клієнтів",
       "bulk": "Масове додавання",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Ліміт подовжень",
       "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.",
+      "renewOnDay": "Подовжувати числа",
+      "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
       "renewsUsed": "Подовжень витрачено"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Увімкнути маршрутизацію",
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
-      "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
+      "subRoutingRulesDesc": "Вставте готове посилання happ:// або одну постійну HTTPS-адресу. Панель оновлює віддалені правила у фоні та зберігає останнє коректне значення, тому запит підписки не чекає на джерело. (Тільки для Happ)",
       "subHideSettings": "Приховати налаштування сервера",
       "subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
       "subIncyEnableRouting": "Увімкнути маршрутизацію",
       "subIncyEnableRoutingDesc": "Вставляти профіль маршрутизації в тіло підписки для клієнта Incy. (Тільки для Incy)",
       "subIncyRoutingRules": "Правила маршрутизації",
-      "subIncyRoutingRulesDesc": "Посилання маршрутизації Incy, що додається в тіло підписки, напр. incy://routing/onadd/<base64>. (Тільки для Incy)",
+      "subIncyRoutingRulesDesc": "Вставте готове посилання incy:// або постійну HTTPS-адресу JSON. Incy створює профіль autorouting і автоматично його оновлює. (Тільки для Incy)",
       "subClashEnableRouting": "Увімкнути маршрутизацію",
       "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
       "subClashRoutingRules": "Глобальні правила маршрутизації",
-      "subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Вставте правила/YAML або одну постійну HTTPS-адресу. Панель оновлює її у фоні, імпортує лише групи, постачальників правил і правила, зберігаючи створені VPN-вузли та останнє коректне значення.",
       "subListen": "Слухати IP",
       "subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
       "subPort": "Слухати порт",
@@ -1368,7 +1380,9 @@
       "secretClear": "Очистити",
       "secretClearUndo": "Скасувати очищення",
       "calendarGregorian": "Григоріанський (звичайний)",
-      "calendarJalalian": "Джалалі (شمسی)"
+      "calendarJalalian": "Джалалі (شمسی)",
+      "ipLimitAllowlist": "Довірені адреси для ліміту",
+      "ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа."
     },
     "xray": {
       "save": "Зберегти",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Закінчення як мітка часу Unix (секунди)",
         "descCREATED_UNIX": "Час створення як мітка часу Unix (секунди)",
         "descRESET_DAYS": "Період скидання трафіку в днях",
+        "descRESET_DAY": "Число місяця, у яке подовжується доступ",
         "descPROTOCOL": "Протокол вхідного (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Транспортна мережа (tcp, ws, grpc, …)",
         "descSECURITY": "Безпека транспорту (TLS, REALITY, NONE)"

+ 21 - 6
internal/web/translation/vi-VN.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "Giữ cài đặt của máy này",
+      "importKeepHostSettingsDesc": "Giữ nguyên địa chỉ lắng nghe, cổng, đường dẫn cơ sở, chứng chỉ và danh tính node của bảng điều khiển này thay vì lấy chúng từ tệp đã tải lên."
     },
     "inbounds": {
       "totalDownUp": "Tổng tải lên/tải xuống",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Quét mục tiêu REALITY thất bại.",
         "scanRealityTargetFeasible": "Mục tiêu khả dụng — đã điền mục tiêu và SNI.",
         "scanRealityTargetNotFeasible": "Mục tiêu có thể truy cập nhưng không khả dụng cho REALITY.",
+        "scanRealityTargetPrivate": "Đích hoạt động nhưng nằm trong mạng riêng/nội bộ.",
         "invalidClientField": "Khách hàng {client}: trường {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} lỗi khác)"
@@ -633,6 +634,11 @@
         "scanCurve": "Trao đổi khóa",
         "scanCert": "Chứng chỉ",
         "scanCertInvalid": "Không tin cậy",
+        "scanCertExpiry": "Chứng chỉ hết hạn",
+        "scanSniUsed": "SNI đã dùng",
+        "scanPrivateNote": "Đã kiểm tra qua mạng riêng/nội bộ — địa chỉ này không truy cập được từ internet.",
+        "scanPrivateConfirmTitle": "Đích trong mạng nội bộ",
+        "scanPrivateConfirmContent": "\"{target}\" trỏ tới địa chỉ riêng hoặc loopback. Việc kiểm tra sẽ bỏ qua bảo vệ SSRF của panel chỉ cho lần thăm dò này. Tiếp tục?",
         "scanLatency": "Độ trễ",
         "scanUse": "Dùng",
         "scanRescan": "Quét lại",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "Thêm đăng ký ngoài",
       "noExternalLinks": "Chưa có liên kết ngoài.",
       "noExternalSubscriptions": "Chưa có đăng ký ngoài.",
+      "namePrefix": "Tiền tố tên",
+      "lastFetchAt": "Lần tải gần nhất",
+      "lastFetchError": "Lỗi tải",
+      "neverFetched": "Chưa tải",
       "submitEdit": "Lưu thay đổi",
       "clientCount": "Số lượng khách hàng",
       "bulk": "Thêm hàng loạt",
@@ -874,6 +884,8 @@
       },
       "renewMax": "Số lần gia hạn tối đa",
       "renewMaxDesc": "Gia hạn tự động được phép chạy bao nhiêu lần trước khi để khách hàng hết hạn. 0 nghĩa là không giới hạn. Bù lại nhiều kỳ đã bỏ lỡ sẽ tiêu tốn một lần gia hạn cho mỗi kỳ.",
+      "renewOnDay": "Gia hạn vào ngày",
+      "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
       "renewsUsed": "Số lần gia hạn đã dùng"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "Bật định tuyến",
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
-      "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
+      "subRoutingRulesDesc": "Dán deeplink happ:// có sẵn hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật quy tắc từ xa trong nền và giữ giá trị hợp lệ gần nhất, nên yêu cầu đăng ký không phải chờ nguồn. (Chỉ dành cho Happ)",
       "subHideSettings": "Ẩn cài đặt máy chủ",
       "subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subIncyEnableRouting": "Bật định tuyến",
       "subIncyEnableRoutingDesc": "Chèn hồ sơ định tuyến vào nội dung đăng ký cho ứng dụng Incy. (Chỉ dành cho Incy)",
       "subIncyRoutingRules": "Quy tắc định tuyến",
-      "subIncyRoutingRulesDesc": "Liên kết định tuyến Incy được thêm vào nội dung đăng ký, ví dụ incy://routing/onadd/<base64>. (Chỉ dành cho Incy)",
+      "subIncyRoutingRulesDesc": "Dán deeplink incy:// có sẵn hoặc URL HTTPS cố định tới JSON. Incy tạo hồ sơ autorouting và tự động cập nhật. (Chỉ dành cho Incy)",
       "subClashEnableRouting": "Bật định tuyến",
       "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
       "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
-      "subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Dán quy tắc/YAML hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật trong nền, chỉ nhập nhóm, nhà cung cấp quy tắc và quy tắc, đồng thời giữ các nút VPN đã tạo và giá trị hợp lệ gần nhất.",
       "subListen": "Listening IP",
       "subListenDesc": "Mặc định để trống để nghe tất cả các IP",
       "subPort": "Cổng gói đăng ký",
@@ -1368,7 +1380,9 @@
       "secretClear": "Xóa",
       "secretClearUndo": "Hoàn tác xóa",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Danh sách cho phép của giới hạn IP",
+      "ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy."
     },
     "xray": {
       "importRules": "Nhập quy tắc",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "Hết hạn dạng dấu thời gian Unix (giây)",
         "descCREATED_UNIX": "Thời điểm tạo dạng dấu thời gian Unix (giây)",
         "descRESET_DAYS": "Chu kỳ đặt lại lưu lượng tính theo ngày",
+        "descRESET_DAY": "Ngày trong tháng để gia hạn",
         "descPROTOCOL": "Giao thức inbound (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Mạng truyền tải (tcp, ws, grpc, …)",
         "descSECURITY": "Bảo mật truyền tải (TLS, REALITY, NONE)"

+ 21 - 6
internal/web/translation/zh-CN.json

@@ -267,8 +267,8 @@
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
       "accessProxy": "PROXY",
-      "importKeepHostSettings": "Keep this machine's settings",
-      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
+      "importKeepHostSettings": "保留本机设置",
+      "importKeepHostSettingsDesc": "保留本面板的监听地址、端口、基础路径、证书和节点身份,而不是使用上传文件中的值。"
     },
     "inbounds": {
       "totalDownUp": "总上传 / 下载",
@@ -441,6 +441,7 @@
         "scanRealityTargetError": "扫描 REALITY 目标失败。",
         "scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。",
         "scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。",
+        "scanRealityTargetPrivate": "目标可用,但位于内网/本地网络中。",
         "invalidClientField": "客户端 {client}:字段 {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (另有 {count} 项)"
@@ -632,6 +633,11 @@
         "scanCurve": "密钥交换",
         "scanCert": "证书",
         "scanCertInvalid": "不受信任",
+        "scanCertExpiry": "证书有效期至",
+        "scanSniUsed": "使用的 SNI",
+        "scanPrivateNote": "已通过内网/本地网络检测 — 该地址无法从互联网访问。",
+        "scanPrivateConfirmTitle": "目标位于本地网络",
+        "scanPrivateConfirmContent": "“{target}”解析到内网或回环地址。本次检测将仅为此探测跳过面板的 SSRF 防护。是否继续?",
         "scanLatency": "延迟",
         "scanUse": "使用",
         "scanRescan": "重新扫描",
@@ -687,6 +693,10 @@
       "addExternalSubscription": "添加外部订阅",
       "noExternalLinks": "暂无外部链接。",
       "noExternalSubscriptions": "暂无外部订阅。",
+      "namePrefix": "名称前缀",
+      "lastFetchAt": "最后拉取",
+      "lastFetchError": "拉取失败",
+      "neverFetched": "尚未拉取",
       "submitEdit": "保存更改",
       "clientCount": "客户端数量",
       "bulk": "批量添加",
@@ -874,6 +884,8 @@
       },
       "renewMax": "最大续期次数",
       "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。",
+      "renewOnDay": "按日期续期",
+      "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
       "renewsUsed": "已用续期次数"
     },
     "groups": {
@@ -1146,17 +1158,17 @@
       "subEnableRouting": "启用路由",
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(仅限 Happ)",
       "subRoutingRules": "路由规则",
-      "subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
+      "subRoutingRulesDesc": "粘贴现成的 happ:// 深层链接或一个固定 HTTPS URL。面板会在后台更新远程规则并保留最后一个有效值,因此订阅请求无需等待远程源。(仅限 Happ)",
       "subHideSettings": "隐藏服务器设置",
       "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
       "subIncyEnableRouting": "启用路由",
       "subIncyEnableRoutingDesc": "为 Incy 客户端将路由配置注入订阅内容中。(仅限 Incy)",
       "subIncyRoutingRules": "路由规则",
-      "subIncyRoutingRulesDesc": "添加到订阅内容的 Incy 路由深层链接,例如 incy://routing/onadd/<base64>。(仅限 Incy)",
+      "subIncyRoutingRulesDesc": "粘贴现成的 incy:// 深层链接或指向 JSON 的固定 HTTPS URL。Incy 会创建 autorouting 配置并自动更新。(仅限 Incy)",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashRoutingRules": "全局路由规则",
-      "subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
+      "subClashRoutingRulesDesc": "粘贴规则/YAML 或一个固定 HTTPS URL。面板会在后台更新,仅导入代理组、规则提供者和规则,并保留面板生成的 VPN 节点及最后一个有效值。",
       "subListen": "监听 IP",
       "subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
       "subPort": "监听端口",
@@ -1368,7 +1380,9 @@
       "secretClear": "清除",
       "secretClearUndo": "撤销清除",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP 限制白名单",
+      "ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。"
     },
     "xray": {
       "importRules": "导入规则",
@@ -1884,6 +1898,7 @@
         "descEXPIRE_UNIX": "到期时间的 Unix 时间戳(秒)",
         "descCREATED_UNIX": "创建时间的 Unix 时间戳(秒)",
         "descRESET_DAYS": "流量重置周期(天)",
+        "descRESET_DAY": "按月续期的日期",
         "descPROTOCOL": "入站协议(VLESS、VMess、Trojan……)",
         "descTRANSPORT": "传输网络(tcp、ws、grpc……)",
         "descSECURITY": "传输安全(TLS、REALITY、NONE)"

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů