30 Commits dcaadd4857 ... 12d51d7195

Autore SHA1 Messaggio Data
  MHSanaei 12d51d7195 perf(tests): copy a migrated template DB instead of migrating per test 2 ore fa
  mrchatam 33a469315a feat(clients): preserve traffic counters in portable export/import (#6469) 2 ore fa
  MHSanaei ac43b19cfa chore(ci): stop release and CodeQL runs on PRs, drop deploy smoke tests 2 ore fa
  DIMFLIX 0ef94b686e feat(tgbot): add /broadcast to relay an admin message to all clients (#6510) 3 ore fa
  DIMFLIX 71e38367c1 feat(sub): add Incy app-management parameters (#6650) 4 ore fa
  mrchatam bd9ccde1f4 feat(sub): make external subscription fetch User-Agent configurable (#6613) 5 ore fa
  Jack 9672249edb feat(clients): add calendar weekly renewal and schedule previews (#6524) 6 ore fa
  NgaiYeanCoi 5e15120cec feat(happ): add routing editor, optional ad blocking, and LAN bypass preset (#6545) 6 ore fa
  MHSanaei 94fa317e76 chore(gen): regenerate types for addrFamily 6 ore fa
  Kirill Rudenko 788b76c544 fix(amneziawg): sniff the relay with routeOnly; scope the v6 egress to IPv6 (#6654) 7 ore fa
  SERGE BLCHV 6ac0c88084 fix(xray): hot-apply Hysteria client changes without replacing the inbound (#6606) 7 ore fa
  Kirill Rudenko 6ab718f813 fix(sub): make Happ require auth on its local SOCKS/HTTP proxy (#6628) 7 ore fa
  Kirill Rudenko 8979072bd9 fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642) 7 ore fa
  Xinny Lin f3b100282a fix: allow IPv4 and IPv6 inbounds to share a port (#6603) 7 ore fa
  Roman Chesnakov bd01f923fb fix(tgbot): unstick the add-client wizard's inbound picker (#6621) 7 ore fa
  mrchatam b3a5be9da4 fix(amneziawg): stop AAAA fallback on v4-only tunnels and expose I2–I5 (#6611) 7 ore fa
  mrchatam 8f1201553e fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres (#6612) 7 ore fa
  sdhfsl d42e2133c7 fix(sub): keep serverDescription literal in external link fragments (#6580) 7 ore fa
  sdhfsl a2ca023336 fix(sub): send panel guid as X-HWID on outbound subscription fetch (#6579) 7 ore fa
  MHSanaei 3fe92df7ad docs: adopt correct-fix-over-small-fix and TDD policy 7 ore fa
  mrchatam 169cd86e00 fix(sub): never use X-Real-IP as the subscription host (#6608) 8 ore fa
  n0ctal 66df77665f test(database): give each package its own schema when tests run on PostgreSQL (#6594) 8 ore fa
  mrchatam c54c28d92d fix(sub): drop legacy freedom.domainStrategy from JSON sub template (#6609) 8 ore fa
  SakikoTogawa 5d41e65a3c fix(sub): preserve external VLESS encryption in Clash subscriptions (#6576) 8 ore fa
  Matt Van Horn 0dec3d65ba fix(sub): preserve per-inbound tunnel identity in subscriptions (#6653) 8 ore fa
  libmur-dev 07ee638a50 fix(sub): emit Hysteria certificate pin for Mihomo (#6651) 8 ore fa
  sdhfsl ee2ff48c81 fix(tgbot): scope the add-client wizard to the admin, not the chat (#6604) 8 ore fa
  n0ctal 3b9ca47a4e fix(database): keep the legacy tag cleanup from colliding with an existing tag (#6592) 8 ore fa
  n0ctal c0c0136037 fix(hwid): serialize the device-limit write with its trim (#6591) 8 ore fa
  n0ctal d86a3def85 fix(ip-limit): append fail2ban lines only after the scan commits (#6590) 8 ore fa
100 ha cambiato i file con 6735 aggiunte e 636 eliminazioni
  1. 2 2
      .github/claude/issue-analyst-context.md
  2. 5 5
      .github/workflows/ci.yml
  3. 0 8
      .github/workflows/codeql.yml
  4. 3 12
      .github/workflows/release.yml
  5. 0 69
      .github/workflows/smoke.yml
  6. 47 14
      CLAUDE.md
  7. 7 1
      CONTRIBUTING.md
  8. 2 4
      api_token_cli_test.go
  9. 2 2
      docs/architecture.md
  10. 3 3
      docs/content/docs/en/config/amneziawg.mdx
  11. 63 1
      docs/content/docs/en/config/clients.mdx
  12. 53 26
      docs/content/docs/en/reference/api/clients.mdx
  13. 53 1
      docs/content/docs/zh/config/clients.mdx
  14. 461 5
      docs/public/openapi.json
  15. 461 5
      frontend/public/openapi.json
  16. 85 0
      frontend/src/generated/examples.ts
  17. 355 2
      frontend/src/generated/schemas.ts
  18. 86 0
      frontend/src/generated/types.ts
  19. 90 0
      frontend/src/generated/zod.ts
  20. 1 0
      frontend/src/hooks/useClients.ts
  21. 31 0
      frontend/src/models/setting.ts
  22. 49 3
      frontend/src/pages/api-docs/endpoints.ts
  23. 10 26
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  24. 16 29
      frontend/src/pages/clients/ClientFormModal.tsx
  25. 197 0
      frontend/src/pages/clients/ClientRenewalFields.tsx
  26. 3 3
      frontend/src/pages/inbounds/form/protocols/amneziawg.tsx
  27. 204 0
      frontend/src/pages/settings/HappRoutingEditorModal.tsx
  28. 45 104
      frontend/src/pages/settings/HappSettingsContent.tsx
  29. 488 0
      frontend/src/pages/settings/IncySettingsContent.tsx
  30. 27 24
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  31. 57 27
      frontend/src/pages/settings/happPresets.ts
  32. 90 0
      frontend/src/pages/settings/happRoutingEditor.ts
  33. 7 3
      frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx
  34. 3 0
      frontend/src/schemas/client.ts
  35. 16 0
      frontend/src/schemas/happRouting.ts
  36. 3 3
      frontend/src/schemas/protocols/inbound/amneziawg.ts
  37. 4 3
      frontend/src/schemas/protocols/outbound/amneziawg.ts
  38. 30 0
      frontend/src/schemas/setting.ts
  39. 22 9
      frontend/src/test/amneziawg-schema-cleared.test.ts
  40. 113 0
      frontend/src/test/client-bulk-calendar-renewal.test.tsx
  41. 109 0
      frontend/src/test/client-calendar-renewal.test.tsx
  42. 20 2
      frontend/src/test/client-toggle-traffic-reset.test.tsx
  43. 77 19
      frontend/src/test/happ-presets.test.ts
  44. 156 0
      frontend/src/test/happ-routing-editor-helpers.test.ts
  45. 322 0
      frontend/src/test/happ-routing-editor.test.tsx
  46. 211 0
      frontend/src/test/happ-settings-presets.test.tsx
  47. 84 0
      frontend/src/test/incy-settings-content.test.tsx
  48. 16 13
      frontend/src/test/node-history-panel.test.tsx
  49. 1 1
      go.mod
  50. 22 0
      internal/amneziawg/outbound_test.go
  51. 53 6
      internal/amneziawg/params.go
  52. 63 4
      internal/amneziawg/params_test.go
  53. 11 0
      internal/amneziawgnet/device_test.go
  54. 29 8
      internal/amneziawgnet/dns.go
  55. 107 0
      internal/amneziawgnet/dns_family_test.go
  56. 7 0
      internal/amneziawgnet/egress.go
  57. 90 0
      internal/database/client_weekly_renew_migration_test.go
  58. 118 0
      internal/database/client_weekly_renew_postgres_test.go
  59. 14 0
      internal/database/db.go
  60. 67 0
      internal/database/dbtest/dbtest.go
  61. 36 0
      internal/database/dbtest/dbtest_test.go
  62. 30 0
      internal/database/model/client_renewal_merge_test.go
  63. 48 21
      internal/database/model/model.go
  64. 2 6
      internal/sub/build_urls_test.go
  65. 6 0
      internal/sub/clash_external.go
  66. 124 0
      internal/sub/clash_external_vless_test.go
  67. 46 0
      internal/sub/clash_service.go
  68. 74 0
      internal/sub/clash_service_test.go
  69. 8 0
      internal/sub/controller.go
  70. 1 2
      internal/sub/default.json
  71. 39 0
      internal/sub/default_json_test.go
  72. 1 1
      internal/sub/external_config.go
  73. 5 37
      internal/sub/external_hwid_test.go
  74. 40 0
      internal/sub/external_remark_test.go
  75. 12 35
      internal/sub/external_subscription.go
  76. 50 0
      internal/sub/external_subscription_test.go
  77. 23 0
      internal/sub/forwarded_trust_test.go
  78. 7 0
      internal/sub/happ.go
  79. 34 0
      internal/sub/happ_test.go
  80. 2 4
      internal/sub/host_sub_test.go
  81. 2 4
      internal/sub/hwid_controller_test.go
  82. 180 0
      internal/sub/incy.go
  83. 208 0
      internal/sub/incy_test.go
  84. 2 4
      internal/sub/mutation_audit_test.go
  85. 2 4
      internal/sub/remote_routing_test.go
  86. 71 29
      internal/sub/service.go
  87. 320 0
      internal/sub/service_amneziawg_test.go
  88. 3 8
      internal/sub/service_dedup_test.go
  89. 2 6
      internal/sub/service_info_node_test.go
  90. 2 4
      internal/sub/service_orphaned_stats_test.go
  91. 2 4
      internal/sub/service_sort_test.go
  92. 2 4
      internal/sub/service_userinfo_test.go
  93. 258 0
      internal/sub/service_wireguard_test.go
  94. 32 0
      internal/sub/sub.go
  95. 2 4
      internal/sub/sub_scale_test.go
  96. 95 0
      internal/testpg/isolate.go
  97. 2 4
      internal/web/controller/api_auth_test.go
  98. 19 4
      internal/web/controller/client.go
  99. 50 4
      internal/web/controller/client_partial_apply_test.go
  100. 122 0
      internal/web/controller/client_renewal_preview_test.go

+ 2 - 2
.github/claude/issue-analyst-context.md

@@ -100,8 +100,8 @@ question it already answers.
   subtests and `t.Helper()` on helpers. An assertion must pin the exact value,
   typed error or emitted string — `err != nil` and `len(x) > 0` are findings,
   not nits. Prefer real dependencies: a throwaway DB via
-  `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` with `t.Cleanup`, and
-  `httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template.
+  `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))`
+  (`internal/database/dbtest`), and `httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template.
   A test must FAIL without its fix; one that passes either way certifies
   nothing and then gets cited as proof the fix works.
 

+ 5 - 5
.github/workflows/ci.yml

@@ -83,12 +83,12 @@ jobs:
       - name: PostgreSQL schema and migration tests
         run: |
           set -o pipefail
-          go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
-          # Both must pass. Counting, not SKIP-matching: renaming either test would
+          go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
+          # All must pass. Counting, not SKIP-matching: renaming a test would
           # otherwise leave this step green while testing nothing.
-          passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true)
-          if [ "$passed" -lt 2 ]; then
-            echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2
+          passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
+          if [ "$passed" -lt 3 ]; then
+            echo "expected at least 3 passing PostgreSQL schema tests, got $passed" >&2
             exit 1
           fi
 

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

@@ -11,12 +11,6 @@ on:
       - "go.mod"
       - "go.sum"
       - "frontend/**"
-  pull_request:
-    paths:
-      - "**.go"
-      - "go.mod"
-      - "go.sum"
-      - "frontend/**"
   schedule:
     - cron: "18 2 * * 2"
 
@@ -24,8 +18,6 @@ jobs:
   analyze:
     name: Analyze (${{ matrix.language }})
     runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
-    env:
-      CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
     permissions:
       security-events: write
       packages: read

+ 3 - 12
.github/workflows/release.yml

@@ -2,9 +2,11 @@ name: Release 3X-UI
 
 on:
   workflow_dispatch:
+  # Only main (dev channel) and version tags ship binaries; build any other
+  # branch on demand via workflow_dispatch.
   push:
     branches:
-      - "**"
+      - main
     tags:
       - "v*.*.*"
     paths:
@@ -17,17 +19,6 @@ on:
       - "x-ui.service.arch"
       - "x-ui.service.rhel"
       - ".github/workflows/release.yml"
-  pull_request:
-    paths:
-      - "**.go"
-      - "go.mod"
-      - "go.sum"
-      - "**.sh"
-      - "frontend/**"
-      - "x-ui.service.debian"
-      - "x-ui.service.arch"
-      - "x-ui.service.rhel"
-      - ".github/workflows/release.yml"
 
 jobs:
   build:

+ 0 - 69
.github/workflows/smoke.yml

@@ -1,69 +0,0 @@
-name: Deploy Smoke Tests
-
-# Container smoke test for the unattended (cloud-init) install path.
-# Runs when the install/deploy assets change on a branch push or PR, and
-# again after a release-tag build finishes uploading its assets — passing the
-# tag as an explicit version, so the green result verifies the release
-# actually being shipped. That job deliberately runs the script from the
-# default branch rather than checking out the tag: workflow_run executes in
-# main's cache scope, so executing checked-out code there is a cache-poisoning
-# surface (CodeQL actions/cache-poisoning/poisonable-step), and users pipe
-# main's install.sh anyway.
-# Tag pushes must NOT trigger the unpinned job directly: at that moment
-# releases/latest still points at the previous release (#5756), and a `paths`
-# filter alone cannot exclude them because a brand-new tag ref has no diff
-# base, so it runs on every tag push.
-
-on:
-  push:
-    branches:
-      - "**"
-    paths:
-      - "install.sh"
-      - "deploy/**"
-      - ".github/workflows/smoke.yml"
-  pull_request:
-    paths:
-      - "install.sh"
-      - "deploy/**"
-      - ".github/workflows/smoke.yml"
-  workflow_run:
-    workflows: ["Release 3X-UI"]
-    types: [completed]
-
-permissions:
-  contents: read
-
-jobs:
-  noninteractive-install:
-    if: github.event_name != 'workflow_run'
-    strategy:
-      fail-fast: false
-      matrix:
-        runner: [ubuntu-latest, ubuntu-24.04-arm]
-    runs-on: ${{ matrix.runner }}
-    timeout-minutes: 15
-    steps:
-      - uses: actions/checkout@v7
-      - name: Non-interactive install smoke test
-        run: bash deploy/test/smoke-noninteractive.sh
-
-  release-tag-install:
-    if: >-
-      github.event_name == 'workflow_run' &&
-      github.event.workflow_run.conclusion == 'success' &&
-      github.event.workflow_run.event == 'push' &&
-      startsWith(github.event.workflow_run.head_branch, 'v') &&
-      contains(github.event.workflow_run.head_branch, '.')
-    strategy:
-      fail-fast: false
-      matrix:
-        runner: [ubuntu-latest, ubuntu-24.04-arm]
-    runs-on: ${{ matrix.runner }}
-    timeout-minutes: 15
-    steps:
-      - uses: actions/checkout@v7
-      - name: Pinned release install smoke test
-        env:
-          XUI_SMOKE_VERSION: ${{ github.event.workflow_run.head_branch }}
-        run: bash deploy/test/smoke-noninteractive.sh "$XUI_SMOKE_VERSION"

+ 47 - 14
CLAUDE.md

@@ -75,11 +75,18 @@ file locations when it can answer in one hop.
   share-link or install-command output changes.
 
 ## Hard rules (non-negotiable)
-- Fix size must match bug size. Find the root cause, then make the SMALLEST
-  change that removes it — a one-line guard beats a new subsystem. A small bug
-  does not earn new columns, jobs, abstractions, config knobs or helper layers.
-  If a fix genuinely needs new architecture, say so and get agreement first;
-  never ship it unasked next to the fix.
+- Correct fix over small fix. Find the root cause and fix it the right way, however
+  much code that takes. Size the change by what the correct fix needs, never by
+  line count: when the right fix spans many files, or needs a migration, a shared
+  helper or a new abstraction, write it. A guard that hides the symptom while the
+  cause survives is the wrong fix, however small. Two limits remain:
+  - Everything added must be something the correct fix needs. No speculative
+    knobs, unused extension points or "while I was here" rewrites. Unrelated
+    refactors and cleanups go in their own commit.
+  - Stop and ask only when the right fix needs a decision the code cannot answer:
+    a deliberate user-visible behaviour change, or two sound designs with a real
+    trade-off. Ask with a recommendation. Size alone is never a reason to stop,
+    defer or ship a smaller patch.
 - Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
   carry the meaning first and rename rather than annotate; spend the 2 lines on
   the *why* a name cannot hold — an invariant, an issue number, a non-obvious
@@ -113,19 +120,45 @@ file locations when it can answer in one hop.
   explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
   `docs`, `style`.
 
+## Tests: TDD, and only tests that can fail (Go and frontend)
+- Work red → green → refactor.
+  - Bug: turn the reproduction into a test first, and watch it fail for the
+    reported reason.
+  - Feature: write the test for the first behaviour before writing its code.
+  - Then write the code that makes it pass, and refactor with the suite green.
+
+  If a test was written after the code, prove it anyway: revert the code, watch
+  the test go red, then restore. A test that passes either way is worse than no
+  test. It certifies nothing, and then gets cited as proof the fix works.
+- Every test must name the failure it catches. When no test can reach a change
+  (workflow YAML, pure wiring, layout), say so and name the command that
+  demonstrates it. Never write a stand-in test.
+- Fake tests are forbidden. Delete any you write or meet in the code you touch:
+  - tests of a getter, a constant, a rename, a pure map lookup, or an input the
+    function can never receive;
+  - tests that restate the implementation, such as recomputing the expected
+    value with the same formula or asserting that a mock was called exactly the
+    way the code calls it;
+  - mocking the unit under test, or mocking so much around it that the real
+    code path never runs;
+  - assertions too weak to fail: `err != nil`, `len > 0`, `toBeDefined()`, or
+    `not.toThrow()` alone;
+  - golden files or snapshots regenerated to match whatever the code now outputs;
+  - extra cases that exercise no distinct branch, and tests written to raise
+    coverage.
+
+  One real test that drives the bug through the actual code path beats five
+  that restate the code.
+
 ## Go conventions
 - Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
   `t.Helper()` on helpers. Assert the exact value / typed error / emitted
   string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
-  `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
-  `t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
-  `internal/sub`'s `initSubDB(t)` is the template.
-- A test must fail without its fix. Write it, revert the fix, watch it go red,
-  restore. A test that passes either way is worse than no test: it certifies
-  nothing and then gets cited as proof the fix works.
-- Test what can actually break. No test for a getter, a constant, a rename, a
-  pure map lookup, or inputs the function can never receive. One real test that
-  drives the bug through the actual code path beats five that restate the code.
+  `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))`
+  (`internal/database/dbtest`: copies a once-migrated template and registers
+  `CloseDB` cleanup; a fresh `database.InitDB` costs ~7x more, ~850ms under
+  `-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or
+  migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template.
 - Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
 - Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
   `XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a

+ 7 - 1
CONTRIBUTING.md

@@ -243,11 +243,17 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 
 Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
 
+### Test first, and only tests that can fail
+
+- **Red → green → refactor.** Write the test before the code. For a bug, the test reproduces the report; for a feature, it covers the first behaviour. Watch it fail, write the code that makes it pass, then refactor with the suite green.
+- **Every test catches a named failure.** Don't test getters, constants or renames. Don't restate the implementation, mock the unit under test, write assertions too weak to fail, or regenerate snapshots to match whatever the code now outputs.
+- **Fix the root cause the right way**, even when that takes more code. A small patch that hides the symptom is not a fix.
+
 ### Go conventions
 
 - **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
 - **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
-- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
+- **Real dependencies over mocks.** Get a throwaway DB with `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))` from `internal/database/dbtest`: it copies a once-migrated template (migrating from scratch per test is ~7x slower, worst under `-race`) and closes the DB before `t.TempDir` cleanup (Windows-safe). Keep `database.InitDB` for reopening an existing file or migrating a hand-built legacy DB. Use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
 
 ### Running
 

+ 2 - 4
api_token_cli_test.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 )
@@ -16,10 +17,7 @@ import (
 func newTokenCLIEnv(t *testing.T) {
 	t.Helper()
 	t.Setenv("XUI_DB_FOLDER", t.TempDir())
-	if err := database.InitDB(config.GetDBPath()); err != nil {
-		t.Fatalf("init db: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	dbtest.InitDB(t, config.GetDBPath())
 }
 
 func tokenNames(t *testing.T) []string {

+ 2 - 2
docs/architecture.md

@@ -292,7 +292,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 ├── install.sh / update.sh / x-ui.sh                        # VPS install + management CLI
 ├── x-ui.service.*  / x-ui.rc                               # systemd units (debian/rhel/arch) + rc script
 ├── windows_files/                                          # Windows service support
-└── .github/workflows/        # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml,
+└── .github/workflows/        # CI: ci.yml, codeql.yml, docker.yml, release.yml,
                               #     mutation.yml, cleanup_caches.yml, claude-pr-review.yml,
                               #     claude-issue-analyst.yml
 ```
@@ -583,7 +583,7 @@ root → `go build ./...` / `go run main.go`.
 **Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
 
 **CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
-`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
+`mutation.yml` (mutation testing), `docker.yml` + `release.yml`
 (multi-arch image + release builds), `cleanup_caches.yml`, `claude-pr-review.yml` (PR review
 only - it changes no code), `claude-issue-analyst.yml` (issue triage).
 

+ 3 - 3
docs/content/docs/en/config/amneziawg.mdx

@@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time.
 | ------------ | ---------------------------------------------------------------------------- |
 | **Jc**       | Number of junk packets sent before the handshake.                            |
 | **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`.     |
-| **S1/S2**    | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. |
-| **S3**       | Cookie-reply padding, `0`-`64`.                                               |
+| **S1/S2**    | Padding added to the handshake init/response packets, `0`-`1552` / `0`-`1608`: the packets are `148 + S1` and `92 + S2` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. The panel also rejects `S1 + 56 = S2`, which would give both packets the same size on the wire (amneziawg-go itself accepts it). An AmneziaWG outbound takes the remote server's values as they are, up to `65535`. |
+| **S3**       | Cookie-reply padding, `0`-`1636`: the reply is `64 + S3` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. An outbound, as with S1/S2, takes the remote server's value up to `65535`. |
 | **S4**       | Transport (data) packet padding, `0`-`32`.                                    |
-| **H1-H4**    | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. |
+| **H1-H4**    | Header values that replace WireGuard's message-type field. Each is a single integer or a `low-high` range, and the four must not overlap — amneziawg-go and the kernel module refuse the whole device otherwise. `1`-`4` are WireGuard's own types and the engine default for a blank field: valid, but without a HeaderProtectionKey the type field then reads like plain WireGuard. |
 | **I1-I5**    | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
 | **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
 | **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |

+ 63 - 1
docs/content/docs/en/config/clients.mdx

@@ -20,7 +20,7 @@ inbounds** at once, with per-client traffic accounting.
 | **Limit IP**   | all (except TUIC)     | Max simultaneous source IPs (enforced via Fail2ban).               |
 | **Total (GB)** | all (except TUIC)     | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
 | **Expiry**     | all                   | Date after which the client stops working.                        |
-| **Reset**      | all                   | Auto-renew period in **days** (rolls the quota over).              |
+| **Auto renewal** | all                 | Disabled, fixed interval in days, calendar weekly, or calendar monthly. |
 | **Telegram ID**| all                   | Links the client to a Telegram user for self-service/notifications.|
 | **Sub ID**     | all                   | Subscription identifier grouping this client's links.             |
 | **Group**      | all                   | Optional client group for organization and bulk filtering.        |
@@ -42,6 +42,68 @@ inbounds** at once, with per-client traffic accounting.
 - **Online status** and **last-online** times are tracked per client (and per
   node in multi-node setups).
 
+## Automatic renewal
+
+The individual and bulk-create forms offer one renewal mode at a time:
+
+| Mode | API fields | Schedule |
+| --- | --- | --- |
+| Disabled | `reset=0`, `resetDay=0`, `resetWeekday=0` | The expiry is not renewed. |
+| Fixed interval | `reset=N`, other two fields `0` | Add exactly N × 24 hours to the previous cutoff. |
+| Calendar weekly | `resetWeekday=1..7`, other two fields `0` | Renew at panel-local midnight on Monday (1) through Sunday (7). |
+| Calendar monthly | `resetDay=1..31`, `resetWeekday=0` | Renew at panel-local midnight on that day; missing dates clamp to the month's last day without losing the configured day. |
+
+Calendar weeks stay on the selected weekday across daylight-saving changes;
+they are not equivalent to a fixed seven-day interval. A skipped midnight uses
+the first valid instant of that date; a repeated midnight uses the first one.
+If a timezone skips the entire selected date, the next matching week is used.
+Existing monthly clients
+that also have `reset` set retain monthly precedence. The API rejects weekly
+renewal combined with a positive `reset` or `resetDay`.
+
+For a full calendar month, select **monthly, day 1** and set the initial cutoff
+to the next month's first midnight. For example, `2030-09-01 00:00:00` is valid
+through `2030-08-31 23:59:59`. Day 31 renews at the **start** of the 31st and is
+not the same schedule. The existing optional month-end subscription-header
+display remains a separate setting and is not enabled by this form.
+
+The preview uses the panel's timezone and the same calendar/catch-up calculation
+as automatic renewal. It shows the cutoff, last valid second, next expiry, and
+allowances needed. It is informational: it does not save, activate, reserve, or
+guarantee a future renewal. When no expiry is set, auto-renewal cannot run; an
+explicit button can set the first calendar cutoff. Selecting a mode alone never
+rewrites an existing expiry. First-use clients keep their initial duration, and
+their calendar dates are available after activation.
+
+For legacy last-second calendar cutoffs, the renewal boundary includes the
+existing free alignment to the following midnight. The last-valid-second
+preview still uses the **stored expiry**, not that alignment: an exclusive
+`23:59:59` cutoff is valid through `23:59:58`. Use a next-midnight cutoff for
+full-day validity; the preview itself does not repair the initial expiry.
+
+`resetMax=0` means unlimited renewals. A positive limit counts **each elapsed
+period**, including offline catch-up, not each scheduler tick or attached inbound.
+If the remaining allowances cannot reach a future cutoff, the client stays
+expired and its traffic is not reset. Operator-disabled clients stay disabled.
+
+Renewal already resets client traffic. The separate **periodic traffic reset**
+does not move the expiry and is unchanged; keep it disabled unless you intend an
+additional reset. Quarterly, yearly, and every-N-week/month schedules are not
+part of these modes.
+
+<Callout type="warn">
+  Upgrade the main panel and every participating node before enabling weekly
+  renewal. Older versions ignore `resetWeekday`; a weekly-only client would not
+  auto-renew and, after its expiry or quota is exhausted, can be deleted by
+  **delete depleted clients** because older versions lack the weekly protection.
+  Back up the database and convert weekly schedules to a renewal mode supported
+  by every participating version before downgrading. Merely disabling weekly
+  renewal does not protect a depleted client from deletion. Avoid depleted-client
+  cleanup while a mixed-version fleet or unconverted weekly clients remain.
+  Database upgrades default this new field to `0` and preserve existing limits
+  and dates.
+</Callout>
+
 ## Share links and external links
 
 Every client has share links and a QR code for its inbounds, plus a combined

File diff suppressed because it is too large
+ 53 - 26
docs/content/docs/en/reference/api/clients.mdx


+ 53 - 1
docs/content/docs/zh/config/clients.mdx

@@ -19,7 +19,7 @@ icon: Users
 | **Limit IP**   | 全部(TUIC 除外)      | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。             |
 | **Total (GB)** | 全部(TUIC 除外)      | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
 | **Expiry**     | 全部                  | 该日期之后客户端停止工作。                                       |
-| **Reset**      | 全部                  | 以**天**为单位的自动续期周期(滚动重置配额)。                   |
+| **自动续期**   | 全部                  | 关闭、固定天数、日历每周或日历每月。                             |
 | **Telegram ID**| 全部                  | 将客户端关联到 Telegram 用户,用于自助服务/通知。               |
 | **Sub ID**     | 全部                  | 用于对该客户端链接分组的订阅标识符。                             |
 | **Group**      | 全部                  | 可选的客户端分组,便于组织管理和批量筛选。                       |
@@ -39,6 +39,58 @@ icon: Users
   并从该客户端的操作中清除它们。
 - 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。
 
+## 自动续期
+
+单个客户端和批量创建表单使用统一的续期模式选择:
+
+| 模式 | API 字段 | 续期规则 |
+| --- | --- | --- |
+| 关闭 | `reset=0`、`resetDay=0`、`resetWeekday=0` | 不自动延长到期时间。 |
+| 固定天数 | `reset=N`,另两个字段为 `0` | 从上次截止时间增加 N × 24 小时。 |
+| 日历每周 | `resetWeekday=1..7`,另两个字段为 `0` | 在面板时区每周一(1)至周日(7)的零点续期。 |
+| 日历每月 | `resetDay=1..31`、`resetWeekday=0` | 在面板时区指定日的零点续期;短月取月末,之后仍按原配置日续期。 |
+
+日历每周跨夏令时仍保持指定星期,不等于固定 7 天。若零点不存在,使用
+该日期第一个有效时刻;零点重复时取第一次。若时区跳过整天,则使用
+下一周的同一星期。旧配置同时填写
+`reset` 和 `resetDay` 时继续以每月续期为准。API 不允许每周续期与正数
+`reset` 或 `resetDay` 同时启用。
+
+整自然月应选**每月、1 日**,首次截止时间设置为下月 1 日零点。例如
+`2030-09-01 00:00:00` 表示有效至 `2030-08-31 23:59:59`。
+31 日表示在 31 日**开始时**续期,并不是同一边界。订阅头原有的可选
+月末显示设置仍独立存在,本表单不会自动开启它。
+
+日期预览使用面板时区和后端实际续期的同一套计算,显示截止时间、最后
+有效秒、下次到期时间及需要消耗的续期次数。预览不会保存、激活或预留
+续期,也不保证未来一定续期。未设到期时间时自动续期无法运行,可以
+明确点击按钮设置首次日历截止时间;仅选择模式不会修改已有到期时间。
+“首次使用后开始”保留原来的初始天数,激活后才能确定日历日期。
+
+旧配置以最后一秒为日历截止时间时,续期边界包含原有的不计次数向下个
+零点对齐规则。但最后有效秒仍按**已存储的到期时间**计算,不会假装
+初始时间已被修改:排他截止时间 `23:59:59` 实际有效至 `23:59:58`。
+整天有效应使用下一个零点,预览本身不会修复首次截止时间。
+
+最大续期次数 `resetMax=0` 表示不限次数。正数上限按**每个经过的周期**
+计数,包括离线补续,不按定时任务执行次数或关联入站数量计数。剩余
+次数不足以续到未来时,客户端继续过期且不会重置流量;手动禁用的
+客户端保持禁用。
+
+自动续期本身会重置客户端流量。独立的**定期流量重置**不延长到期时间,
+这次未改变其规则;除非需要额外重置,否则保持关闭。本次不包含季度、
+年度和每 N 周/月的续期。
+
+<Callout type="warn">
+  启用每周续期前,需要升级主面板及所有参与节点。旧版本会忽略
+  `resetWeekday`,仅配置每周的客户端将无法自动续期,且在到期或流量
+  耗尽后,可能被**删除已耗尽客户端**操作删除,因为旧版没有每周续期
+  的清理保护。降级前应备份数据库,并将每周配置转换为所有参与版本
+  都支持的续期模式;仅关闭每周续期并不能防止耗尽后的删除。存在
+  混合版本或尚未转换的每周客户端时,应避免执行耗尽客户端清理。
+  数据库升级默认将新字段设为 `0`,保留已有日期和限制。
+</Callout>
+
 ## 分享链接与外部链接
 
 每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的

+ 461 - 5
docs/public/openapi.json

@@ -69,6 +69,9 @@
             "minimum": 0,
             "type": "integer"
           },
+          "externalSubUserAgent": {
+            "type": "string"
+          },
           "externalTrafficInformEnable": {
             "type": "boolean"
           },
@@ -288,6 +291,9 @@
           "subHappFallbackUrl": {
             "type": "string"
           },
+          "subHappLocalProxyAuth": {
+            "type": "string"
+          },
           "subHappNewUrl": {
             "type": "string"
           },
@@ -336,12 +342,97 @@
           "subHideSettings": {
             "type": "boolean"
           },
+          "subIncyAnnounceUrl": {
+            "type": "string"
+          },
+          "subIncyAppAutoDetect": {
+            "description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
+            "type": "boolean"
+          },
+          "subIncyBannerBgColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonText": {
+            "type": "string"
+          },
+          "subIncyBannerButtonUrl": {
+            "type": "string"
+          },
+          "subIncyBannerText": {
+            "type": "string"
+          },
           "subIncyEnableRouting": {
             "type": "boolean"
           },
+          "subIncyFragmentInterval": {
+            "type": "string"
+          },
+          "subIncyFragmentLength": {
+            "type": "string"
+          },
+          "subIncyFragmentPackets": {
+            "type": "string"
+          },
+          "subIncyFragmentationEnable": {
+            "type": "string"
+          },
+          "subIncyHideCheck": {
+            "type": "string"
+          },
+          "subIncyHideUrl": {
+            "type": "string"
+          },
+          "subIncyNoLimitEnabled": {
+            "type": "string"
+          },
+          "subIncyNoisesDelay": {
+            "type": "string"
+          },
+          "subIncyNoisesEnable": {
+            "type": "string"
+          },
+          "subIncyNoisesPacket": {
+            "type": "string"
+          },
+          "subIncyNoisesType": {
+            "type": "string"
+          },
+          "subIncyPerAppEnable": {
+            "type": "string"
+          },
+          "subIncyPerAppList": {
+            "type": "string"
+          },
+          "subIncyPerAppMode": {
+            "type": "string"
+          },
+          "subIncyPremiumUrl": {
+            "type": "string"
+          },
+          "subIncyProfileDescription": {
+            "type": "string"
+          },
+          "subIncyResolveDnsDomain": {
+            "type": "string"
+          },
+          "subIncyResolveDnsIp": {
+            "type": "string"
+          },
+          "subIncyResolveEnable": {
+            "type": "string"
+          },
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subIncySortOrder": {
+            "type": "string"
+          },
+          "subIncySupportEmail": {
+            "type": "string"
+          },
           "subInfoNodeEnable": {
             "type": "boolean"
           },
@@ -519,6 +610,7 @@
           "discordMemory",
           "discordRunTime",
           "expireDiff",
+          "externalSubUserAgent",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
           "happLinkEnable",
@@ -586,6 +678,7 @@
           "subHappExcludeApns",
           "subHappExcludeRoutes",
           "subHappFallbackUrl",
+          "subHappLocalProxyAuth",
           "subHappNewUrl",
           "subHappNoLimit",
           "subHappNotificationExpire",
@@ -602,8 +695,36 @@
           "subHappTunMode",
           "subHappTunType",
           "subHideSettings",
+          "subIncyAnnounceUrl",
+          "subIncyAppAutoDetect",
+          "subIncyBannerBgColor",
+          "subIncyBannerButtonColor",
+          "subIncyBannerButtonText",
+          "subIncyBannerButtonUrl",
+          "subIncyBannerText",
           "subIncyEnableRouting",
+          "subIncyFragmentInterval",
+          "subIncyFragmentLength",
+          "subIncyFragmentPackets",
+          "subIncyFragmentationEnable",
+          "subIncyHideCheck",
+          "subIncyHideUrl",
+          "subIncyNoLimitEnabled",
+          "subIncyNoisesDelay",
+          "subIncyNoisesEnable",
+          "subIncyNoisesPacket",
+          "subIncyNoisesType",
+          "subIncyPerAppEnable",
+          "subIncyPerAppList",
+          "subIncyPerAppMode",
+          "subIncyPremiumUrl",
+          "subIncyProfileDescription",
+          "subIncyResolveDnsDomain",
+          "subIncyResolveDnsIp",
+          "subIncyResolveEnable",
           "subIncyRoutingRules",
+          "subIncySortOrder",
+          "subIncySupportEmail",
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
@@ -700,6 +821,9 @@
             "minimum": 0,
             "type": "integer"
           },
+          "externalSubUserAgent": {
+            "type": "string"
+          },
           "externalTrafficInformEnable": {
             "type": "boolean"
           },
@@ -943,6 +1067,9 @@
           "subHappFallbackUrl": {
             "type": "string"
           },
+          "subHappLocalProxyAuth": {
+            "type": "string"
+          },
           "subHappNewUrl": {
             "type": "string"
           },
@@ -991,12 +1118,97 @@
           "subHideSettings": {
             "type": "boolean"
           },
+          "subIncyAnnounceUrl": {
+            "type": "string"
+          },
+          "subIncyAppAutoDetect": {
+            "description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
+            "type": "boolean"
+          },
+          "subIncyBannerBgColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonText": {
+            "type": "string"
+          },
+          "subIncyBannerButtonUrl": {
+            "type": "string"
+          },
+          "subIncyBannerText": {
+            "type": "string"
+          },
           "subIncyEnableRouting": {
             "type": "boolean"
           },
+          "subIncyFragmentInterval": {
+            "type": "string"
+          },
+          "subIncyFragmentLength": {
+            "type": "string"
+          },
+          "subIncyFragmentPackets": {
+            "type": "string"
+          },
+          "subIncyFragmentationEnable": {
+            "type": "string"
+          },
+          "subIncyHideCheck": {
+            "type": "string"
+          },
+          "subIncyHideUrl": {
+            "type": "string"
+          },
+          "subIncyNoLimitEnabled": {
+            "type": "string"
+          },
+          "subIncyNoisesDelay": {
+            "type": "string"
+          },
+          "subIncyNoisesEnable": {
+            "type": "string"
+          },
+          "subIncyNoisesPacket": {
+            "type": "string"
+          },
+          "subIncyNoisesType": {
+            "type": "string"
+          },
+          "subIncyPerAppEnable": {
+            "type": "string"
+          },
+          "subIncyPerAppList": {
+            "type": "string"
+          },
+          "subIncyPerAppMode": {
+            "type": "string"
+          },
+          "subIncyPremiumUrl": {
+            "type": "string"
+          },
+          "subIncyProfileDescription": {
+            "type": "string"
+          },
+          "subIncyResolveDnsDomain": {
+            "type": "string"
+          },
+          "subIncyResolveDnsIp": {
+            "type": "string"
+          },
+          "subIncyResolveEnable": {
+            "type": "string"
+          },
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subIncySortOrder": {
+            "type": "string"
+          },
+          "subIncySupportEmail": {
+            "type": "string"
+          },
           "subInfoNodeEnable": {
             "type": "boolean"
           },
@@ -1174,6 +1386,7 @@
           "discordMemory",
           "discordRunTime",
           "expireDiff",
+          "externalSubUserAgent",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
           "happLinkEnable",
@@ -1249,6 +1462,7 @@
           "subHappExcludeApns",
           "subHappExcludeRoutes",
           "subHappFallbackUrl",
+          "subHappLocalProxyAuth",
           "subHappNewUrl",
           "subHappNoLimit",
           "subHappNotificationExpire",
@@ -1265,8 +1479,36 @@
           "subHappTunMode",
           "subHappTunType",
           "subHideSettings",
+          "subIncyAnnounceUrl",
+          "subIncyAppAutoDetect",
+          "subIncyBannerBgColor",
+          "subIncyBannerButtonColor",
+          "subIncyBannerButtonText",
+          "subIncyBannerButtonUrl",
+          "subIncyBannerText",
           "subIncyEnableRouting",
+          "subIncyFragmentInterval",
+          "subIncyFragmentLength",
+          "subIncyFragmentPackets",
+          "subIncyFragmentationEnable",
+          "subIncyHideCheck",
+          "subIncyHideUrl",
+          "subIncyNoLimitEnabled",
+          "subIncyNoisesDelay",
+          "subIncyNoisesEnable",
+          "subIncyNoisesPacket",
+          "subIncyNoisesType",
+          "subIncyPerAppEnable",
+          "subIncyPerAppList",
+          "subIncyPerAppMode",
+          "subIncyPremiumUrl",
+          "subIncyProfileDescription",
+          "subIncyResolveDnsDomain",
+          "subIncyResolveDnsIp",
+          "subIncyResolveEnable",
           "subIncyRoutingRules",
+          "subIncySortOrder",
+          "subIncySupportEmail",
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
@@ -1523,13 +1765,17 @@
             "type": "integer"
           },
           "resetDay": {
-            "description": "Calendar renewal day 1-31, 0 = interval mode",
+            "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
             "type": "integer"
           },
           "resetMax": {
             "description": "Max auto-renew count, 0 = unlimited",
             "type": "integer"
           },
+          "resetWeekday": {
+            "description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
+            "type": "integer"
+          },
           "reverse": {
             "allOf": [
               {
@@ -1592,6 +1838,7 @@
           "reset",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "security",
           "subId",
           "tgId",
@@ -1743,6 +1990,9 @@
           "resetMax": {
             "type": "integer"
           },
+          "resetWeekday": {
+            "type": "integer"
+          },
           "reverse": {},
           "secret": {
             "type": "string"
@@ -1798,6 +2048,7 @@
           "reset",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "reverse",
           "secret",
           "security",
@@ -1811,6 +2062,97 @@
         ],
         "type": "object"
       },
+      "ClientRenewalPreview": {
+        "properties": {
+          "canRenew": {
+            "example": true,
+            "type": "boolean"
+          },
+          "delayedStart": {
+            "example": false,
+            "type": "boolean"
+          },
+          "nextExpiry": {
+            "example": "2030-02-01T00:00:00Z",
+            "type": "string"
+          },
+          "renewAt": {
+            "example": "2030-01-01T00:00:00Z",
+            "type": "string"
+          },
+          "renewals": {
+            "example": 1,
+            "type": "integer"
+          },
+          "suggestedExpiry": {
+            "example": "2030-01-01T00:00:00Z",
+            "type": "string"
+          },
+          "suggestedExpiryTime": {
+            "example": 1893456000000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "timeZone": {
+            "example": "UTC",
+            "type": "string"
+          },
+          "validThrough": {
+            "example": "2029-12-31T23:59:59Z",
+            "type": "string"
+          }
+        },
+        "required": [
+          "canRenew",
+          "delayedStart",
+          "nextExpiry",
+          "renewAt",
+          "renewals",
+          "suggestedExpiry",
+          "suggestedExpiryTime",
+          "timeZone",
+          "validThrough"
+        ],
+        "type": "object"
+      },
+      "ClientRenewalPreviewRequest": {
+        "properties": {
+          "expiryTime": {
+            "example": 1893456000000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "reset": {
+            "example": 0,
+            "type": "integer"
+          },
+          "resetCount": {
+            "example": 0,
+            "type": "integer"
+          },
+          "resetDay": {
+            "example": 1,
+            "type": "integer"
+          },
+          "resetMax": {
+            "example": 0,
+            "type": "integer"
+          },
+          "resetWeekday": {
+            "example": 0,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "expiryTime",
+          "reset",
+          "resetCount",
+          "resetDay",
+          "resetMax",
+          "resetWeekday"
+        ],
+        "type": "object"
+      },
       "ClientReverse": {
         "properties": {
           "tag": {
@@ -1881,6 +2223,10 @@
             "example": 0,
             "type": "integer"
           },
+          "resetWeekday": {
+            "example": 0,
+            "type": "integer"
+          },
           "subId": {
             "example": "abcd1234",
             "type": "string"
@@ -1915,6 +2261,7 @@
           "reset",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "subId",
           "totalGB",
           "updatedAt"
@@ -1970,7 +2317,7 @@
             "type": "integer"
           },
           "resetDay": {
-            "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+            "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
             "example": 0,
             "type": "integer"
           },
@@ -1979,6 +2326,11 @@
             "example": 0,
             "type": "integer"
           },
+          "resetWeekday": {
+            "description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
+            "example": 0,
+            "type": "integer"
+          },
           "subId": {
             "example": "i7tvdpeffi0hvvf1",
             "type": "string"
@@ -2011,6 +2363,7 @@
           "resetCount",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "subId",
           "total",
           "up",
@@ -4799,6 +5152,7 @@
                           "resetCount": 0,
                           "resetDay": 0,
                           "resetMax": 0,
+                          "resetWeekday": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
                           "up": 1048576,
@@ -8033,6 +8387,7 @@
                         "reset": 0,
                         "resetDay": 0,
                         "resetMax": 0,
+                        "resetWeekday": 0,
                         "subId": "abcd1234",
                         "totalGB": 53687091200,
                         "traffic": null,
@@ -8225,6 +8580,97 @@
         }
       }
     },
+    "/panel/api/clients/renewalPreview": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Preview client auto-renewal dates without saving or resetting anything.",
+        "operationId": "post_panel_api_clients_renewalPreview",
+        "description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "expiryTime": {
+                    "type": "integer",
+                    "description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
+                  },
+                  "reset": {
+                    "type": "integer",
+                    "description": "Fixed interval in days; 0 disabled."
+                  },
+                  "resetDay": {
+                    "type": "integer",
+                    "description": "Monthly calendar day 1-31; 0 disabled."
+                  },
+                  "resetWeekday": {
+                    "type": "integer",
+                    "description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
+                  },
+                  "resetMax": {
+                    "type": "integer",
+                    "description": "Maximum renewals; 0 unlimited."
+                  },
+                  "resetCount": {
+                    "type": "integer",
+                    "description": "Renewals already consumed; defaults to 0."
+                  }
+                },
+                "required": [
+                  "expiryTime",
+                  "reset",
+                  "resetDay",
+                  "resetWeekday",
+                  "resetMax",
+                  "resetCount"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/ClientRenewalPreview"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "canRenew": true,
+                    "delayedStart": false,
+                    "nextExpiry": "2030-02-01T00:00:00Z",
+                    "renewAt": "2030-01-01T00:00:00Z",
+                    "renewals": 1,
+                    "suggestedExpiry": "2030-01-01T00:00:00Z",
+                    "suggestedExpiryTime": 1893456000000,
+                    "timeZone": "UTC",
+                    "validThrough": "2029-12-31T23:59:59Z"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/update/{email}": {
       "post": {
         "tags": [
@@ -8684,7 +9130,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
+        "summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
         "operationId": "get_panel_api_clients_export",
         "responses": {
           "200": {
@@ -8719,7 +9165,13 @@
                       "inboundIds": [
                         7,
                         9
-                      ]
+                      ],
+                      "traffic": {
+                        "up": 1048576,
+                        "down": 2097152,
+                        "resetCount": 0,
+                        "lastOnline": 1735680000000
+                      }
                     }
                   ]
                 }
@@ -8734,7 +9186,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
+        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
         "operationId": "post_panel_api_clients_import",
         "requestBody": {
           "required": true,
@@ -10284,6 +10736,7 @@
                     "resetCount": 0,
                     "resetDay": 0,
                     "resetMax": 0,
+                    "resetWeekday": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,
@@ -15657,6 +16110,7 @@
                     "resetCount": 0,
                     "resetDay": 0,
                     "resetMax": 0,
+                    "resetWeekday": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,
@@ -15739,6 +16193,7 @@
                     "resetCount": 0,
                     "resetDay": 0,
                     "resetMax": 0,
+                    "resetWeekday": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,
@@ -15785,6 +16240,7 @@
                       "resetCount": 0,
                       "resetDay": 0,
                       "resetMax": 0,
+                      "resetWeekday": 0,
                       "subId": "i7tvdpeffi0hvvf1",
                       "total": 10737418240,
                       "up": 1048576,

+ 461 - 5
frontend/public/openapi.json

@@ -69,6 +69,9 @@
             "minimum": 0,
             "type": "integer"
           },
+          "externalSubUserAgent": {
+            "type": "string"
+          },
           "externalTrafficInformEnable": {
             "type": "boolean"
           },
@@ -288,6 +291,9 @@
           "subHappFallbackUrl": {
             "type": "string"
           },
+          "subHappLocalProxyAuth": {
+            "type": "string"
+          },
           "subHappNewUrl": {
             "type": "string"
           },
@@ -336,12 +342,97 @@
           "subHideSettings": {
             "type": "boolean"
           },
+          "subIncyAnnounceUrl": {
+            "type": "string"
+          },
+          "subIncyAppAutoDetect": {
+            "description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
+            "type": "boolean"
+          },
+          "subIncyBannerBgColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonText": {
+            "type": "string"
+          },
+          "subIncyBannerButtonUrl": {
+            "type": "string"
+          },
+          "subIncyBannerText": {
+            "type": "string"
+          },
           "subIncyEnableRouting": {
             "type": "boolean"
           },
+          "subIncyFragmentInterval": {
+            "type": "string"
+          },
+          "subIncyFragmentLength": {
+            "type": "string"
+          },
+          "subIncyFragmentPackets": {
+            "type": "string"
+          },
+          "subIncyFragmentationEnable": {
+            "type": "string"
+          },
+          "subIncyHideCheck": {
+            "type": "string"
+          },
+          "subIncyHideUrl": {
+            "type": "string"
+          },
+          "subIncyNoLimitEnabled": {
+            "type": "string"
+          },
+          "subIncyNoisesDelay": {
+            "type": "string"
+          },
+          "subIncyNoisesEnable": {
+            "type": "string"
+          },
+          "subIncyNoisesPacket": {
+            "type": "string"
+          },
+          "subIncyNoisesType": {
+            "type": "string"
+          },
+          "subIncyPerAppEnable": {
+            "type": "string"
+          },
+          "subIncyPerAppList": {
+            "type": "string"
+          },
+          "subIncyPerAppMode": {
+            "type": "string"
+          },
+          "subIncyPremiumUrl": {
+            "type": "string"
+          },
+          "subIncyProfileDescription": {
+            "type": "string"
+          },
+          "subIncyResolveDnsDomain": {
+            "type": "string"
+          },
+          "subIncyResolveDnsIp": {
+            "type": "string"
+          },
+          "subIncyResolveEnable": {
+            "type": "string"
+          },
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subIncySortOrder": {
+            "type": "string"
+          },
+          "subIncySupportEmail": {
+            "type": "string"
+          },
           "subInfoNodeEnable": {
             "type": "boolean"
           },
@@ -519,6 +610,7 @@
           "discordMemory",
           "discordRunTime",
           "expireDiff",
+          "externalSubUserAgent",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
           "happLinkEnable",
@@ -586,6 +678,7 @@
           "subHappExcludeApns",
           "subHappExcludeRoutes",
           "subHappFallbackUrl",
+          "subHappLocalProxyAuth",
           "subHappNewUrl",
           "subHappNoLimit",
           "subHappNotificationExpire",
@@ -602,8 +695,36 @@
           "subHappTunMode",
           "subHappTunType",
           "subHideSettings",
+          "subIncyAnnounceUrl",
+          "subIncyAppAutoDetect",
+          "subIncyBannerBgColor",
+          "subIncyBannerButtonColor",
+          "subIncyBannerButtonText",
+          "subIncyBannerButtonUrl",
+          "subIncyBannerText",
           "subIncyEnableRouting",
+          "subIncyFragmentInterval",
+          "subIncyFragmentLength",
+          "subIncyFragmentPackets",
+          "subIncyFragmentationEnable",
+          "subIncyHideCheck",
+          "subIncyHideUrl",
+          "subIncyNoLimitEnabled",
+          "subIncyNoisesDelay",
+          "subIncyNoisesEnable",
+          "subIncyNoisesPacket",
+          "subIncyNoisesType",
+          "subIncyPerAppEnable",
+          "subIncyPerAppList",
+          "subIncyPerAppMode",
+          "subIncyPremiumUrl",
+          "subIncyProfileDescription",
+          "subIncyResolveDnsDomain",
+          "subIncyResolveDnsIp",
+          "subIncyResolveEnable",
           "subIncyRoutingRules",
+          "subIncySortOrder",
+          "subIncySupportEmail",
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
@@ -700,6 +821,9 @@
             "minimum": 0,
             "type": "integer"
           },
+          "externalSubUserAgent": {
+            "type": "string"
+          },
           "externalTrafficInformEnable": {
             "type": "boolean"
           },
@@ -943,6 +1067,9 @@
           "subHappFallbackUrl": {
             "type": "string"
           },
+          "subHappLocalProxyAuth": {
+            "type": "string"
+          },
           "subHappNewUrl": {
             "type": "string"
           },
@@ -991,12 +1118,97 @@
           "subHideSettings": {
             "type": "boolean"
           },
+          "subIncyAnnounceUrl": {
+            "type": "string"
+          },
+          "subIncyAppAutoDetect": {
+            "description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
+            "type": "boolean"
+          },
+          "subIncyBannerBgColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonColor": {
+            "type": "string"
+          },
+          "subIncyBannerButtonText": {
+            "type": "string"
+          },
+          "subIncyBannerButtonUrl": {
+            "type": "string"
+          },
+          "subIncyBannerText": {
+            "type": "string"
+          },
           "subIncyEnableRouting": {
             "type": "boolean"
           },
+          "subIncyFragmentInterval": {
+            "type": "string"
+          },
+          "subIncyFragmentLength": {
+            "type": "string"
+          },
+          "subIncyFragmentPackets": {
+            "type": "string"
+          },
+          "subIncyFragmentationEnable": {
+            "type": "string"
+          },
+          "subIncyHideCheck": {
+            "type": "string"
+          },
+          "subIncyHideUrl": {
+            "type": "string"
+          },
+          "subIncyNoLimitEnabled": {
+            "type": "string"
+          },
+          "subIncyNoisesDelay": {
+            "type": "string"
+          },
+          "subIncyNoisesEnable": {
+            "type": "string"
+          },
+          "subIncyNoisesPacket": {
+            "type": "string"
+          },
+          "subIncyNoisesType": {
+            "type": "string"
+          },
+          "subIncyPerAppEnable": {
+            "type": "string"
+          },
+          "subIncyPerAppList": {
+            "type": "string"
+          },
+          "subIncyPerAppMode": {
+            "type": "string"
+          },
+          "subIncyPremiumUrl": {
+            "type": "string"
+          },
+          "subIncyProfileDescription": {
+            "type": "string"
+          },
+          "subIncyResolveDnsDomain": {
+            "type": "string"
+          },
+          "subIncyResolveDnsIp": {
+            "type": "string"
+          },
+          "subIncyResolveEnable": {
+            "type": "string"
+          },
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subIncySortOrder": {
+            "type": "string"
+          },
+          "subIncySupportEmail": {
+            "type": "string"
+          },
           "subInfoNodeEnable": {
             "type": "boolean"
           },
@@ -1174,6 +1386,7 @@
           "discordMemory",
           "discordRunTime",
           "expireDiff",
+          "externalSubUserAgent",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
           "happLinkEnable",
@@ -1249,6 +1462,7 @@
           "subHappExcludeApns",
           "subHappExcludeRoutes",
           "subHappFallbackUrl",
+          "subHappLocalProxyAuth",
           "subHappNewUrl",
           "subHappNoLimit",
           "subHappNotificationExpire",
@@ -1265,8 +1479,36 @@
           "subHappTunMode",
           "subHappTunType",
           "subHideSettings",
+          "subIncyAnnounceUrl",
+          "subIncyAppAutoDetect",
+          "subIncyBannerBgColor",
+          "subIncyBannerButtonColor",
+          "subIncyBannerButtonText",
+          "subIncyBannerButtonUrl",
+          "subIncyBannerText",
           "subIncyEnableRouting",
+          "subIncyFragmentInterval",
+          "subIncyFragmentLength",
+          "subIncyFragmentPackets",
+          "subIncyFragmentationEnable",
+          "subIncyHideCheck",
+          "subIncyHideUrl",
+          "subIncyNoLimitEnabled",
+          "subIncyNoisesDelay",
+          "subIncyNoisesEnable",
+          "subIncyNoisesPacket",
+          "subIncyNoisesType",
+          "subIncyPerAppEnable",
+          "subIncyPerAppList",
+          "subIncyPerAppMode",
+          "subIncyPremiumUrl",
+          "subIncyProfileDescription",
+          "subIncyResolveDnsDomain",
+          "subIncyResolveDnsIp",
+          "subIncyResolveEnable",
           "subIncyRoutingRules",
+          "subIncySortOrder",
+          "subIncySupportEmail",
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
@@ -1523,13 +1765,17 @@
             "type": "integer"
           },
           "resetDay": {
-            "description": "Calendar renewal day 1-31, 0 = interval mode",
+            "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
             "type": "integer"
           },
           "resetMax": {
             "description": "Max auto-renew count, 0 = unlimited",
             "type": "integer"
           },
+          "resetWeekday": {
+            "description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
+            "type": "integer"
+          },
           "reverse": {
             "allOf": [
               {
@@ -1592,6 +1838,7 @@
           "reset",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "security",
           "subId",
           "tgId",
@@ -1743,6 +1990,9 @@
           "resetMax": {
             "type": "integer"
           },
+          "resetWeekday": {
+            "type": "integer"
+          },
           "reverse": {},
           "secret": {
             "type": "string"
@@ -1798,6 +2048,7 @@
           "reset",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "reverse",
           "secret",
           "security",
@@ -1811,6 +2062,97 @@
         ],
         "type": "object"
       },
+      "ClientRenewalPreview": {
+        "properties": {
+          "canRenew": {
+            "example": true,
+            "type": "boolean"
+          },
+          "delayedStart": {
+            "example": false,
+            "type": "boolean"
+          },
+          "nextExpiry": {
+            "example": "2030-02-01T00:00:00Z",
+            "type": "string"
+          },
+          "renewAt": {
+            "example": "2030-01-01T00:00:00Z",
+            "type": "string"
+          },
+          "renewals": {
+            "example": 1,
+            "type": "integer"
+          },
+          "suggestedExpiry": {
+            "example": "2030-01-01T00:00:00Z",
+            "type": "string"
+          },
+          "suggestedExpiryTime": {
+            "example": 1893456000000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "timeZone": {
+            "example": "UTC",
+            "type": "string"
+          },
+          "validThrough": {
+            "example": "2029-12-31T23:59:59Z",
+            "type": "string"
+          }
+        },
+        "required": [
+          "canRenew",
+          "delayedStart",
+          "nextExpiry",
+          "renewAt",
+          "renewals",
+          "suggestedExpiry",
+          "suggestedExpiryTime",
+          "timeZone",
+          "validThrough"
+        ],
+        "type": "object"
+      },
+      "ClientRenewalPreviewRequest": {
+        "properties": {
+          "expiryTime": {
+            "example": 1893456000000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "reset": {
+            "example": 0,
+            "type": "integer"
+          },
+          "resetCount": {
+            "example": 0,
+            "type": "integer"
+          },
+          "resetDay": {
+            "example": 1,
+            "type": "integer"
+          },
+          "resetMax": {
+            "example": 0,
+            "type": "integer"
+          },
+          "resetWeekday": {
+            "example": 0,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "expiryTime",
+          "reset",
+          "resetCount",
+          "resetDay",
+          "resetMax",
+          "resetWeekday"
+        ],
+        "type": "object"
+      },
       "ClientReverse": {
         "properties": {
           "tag": {
@@ -1881,6 +2223,10 @@
             "example": 0,
             "type": "integer"
           },
+          "resetWeekday": {
+            "example": 0,
+            "type": "integer"
+          },
           "subId": {
             "example": "abcd1234",
             "type": "string"
@@ -1915,6 +2261,7 @@
           "reset",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "subId",
           "totalGB",
           "updatedAt"
@@ -1970,7 +2317,7 @@
             "type": "integer"
           },
           "resetDay": {
-            "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+            "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
             "example": 0,
             "type": "integer"
           },
@@ -1979,6 +2326,11 @@
             "example": 0,
             "type": "integer"
           },
+          "resetWeekday": {
+            "description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
+            "example": 0,
+            "type": "integer"
+          },
           "subId": {
             "example": "i7tvdpeffi0hvvf1",
             "type": "string"
@@ -2011,6 +2363,7 @@
           "resetCount",
           "resetDay",
           "resetMax",
+          "resetWeekday",
           "subId",
           "total",
           "up",
@@ -4799,6 +5152,7 @@
                           "resetCount": 0,
                           "resetDay": 0,
                           "resetMax": 0,
+                          "resetWeekday": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
                           "up": 1048576,
@@ -8033,6 +8387,7 @@
                         "reset": 0,
                         "resetDay": 0,
                         "resetMax": 0,
+                        "resetWeekday": 0,
                         "subId": "abcd1234",
                         "totalGB": 53687091200,
                         "traffic": null,
@@ -8225,6 +8580,97 @@
         }
       }
     },
+    "/panel/api/clients/renewalPreview": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Preview client auto-renewal dates without saving or resetting anything.",
+        "operationId": "post_panel_api_clients_renewalPreview",
+        "description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "expiryTime": {
+                    "type": "integer",
+                    "description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
+                  },
+                  "reset": {
+                    "type": "integer",
+                    "description": "Fixed interval in days; 0 disabled."
+                  },
+                  "resetDay": {
+                    "type": "integer",
+                    "description": "Monthly calendar day 1-31; 0 disabled."
+                  },
+                  "resetWeekday": {
+                    "type": "integer",
+                    "description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
+                  },
+                  "resetMax": {
+                    "type": "integer",
+                    "description": "Maximum renewals; 0 unlimited."
+                  },
+                  "resetCount": {
+                    "type": "integer",
+                    "description": "Renewals already consumed; defaults to 0."
+                  }
+                },
+                "required": [
+                  "expiryTime",
+                  "reset",
+                  "resetDay",
+                  "resetWeekday",
+                  "resetMax",
+                  "resetCount"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/ClientRenewalPreview"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "canRenew": true,
+                    "delayedStart": false,
+                    "nextExpiry": "2030-02-01T00:00:00Z",
+                    "renewAt": "2030-01-01T00:00:00Z",
+                    "renewals": 1,
+                    "suggestedExpiry": "2030-01-01T00:00:00Z",
+                    "suggestedExpiryTime": 1893456000000,
+                    "timeZone": "UTC",
+                    "validThrough": "2029-12-31T23:59:59Z"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/update/{email}": {
       "post": {
         "tags": [
@@ -8684,7 +9130,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
+        "summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
         "operationId": "get_panel_api_clients_export",
         "responses": {
           "200": {
@@ -8719,7 +9165,13 @@
                       "inboundIds": [
                         7,
                         9
-                      ]
+                      ],
+                      "traffic": {
+                        "up": 1048576,
+                        "down": 2097152,
+                        "resetCount": 0,
+                        "lastOnline": 1735680000000
+                      }
                     }
                   ]
                 }
@@ -8734,7 +9186,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
+        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
         "operationId": "post_panel_api_clients_import",
         "requestBody": {
           "required": true,
@@ -10284,6 +10736,7 @@
                     "resetCount": 0,
                     "resetDay": 0,
                     "resetMax": 0,
+                    "resetWeekday": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,
@@ -15657,6 +16110,7 @@
                     "resetCount": 0,
                     "resetDay": 0,
                     "resetMax": 0,
+                    "resetWeekday": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,
@@ -15739,6 +16193,7 @@
                     "resetCount": 0,
                     "resetDay": 0,
                     "resetMax": 0,
+                    "resetWeekday": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,
@@ -15785,6 +16240,7 @@
                       "resetCount": 0,
                       "resetDay": 0,
                       "resetMax": 0,
+                      "resetWeekday": 0,
                       "subId": "i7tvdpeffi0hvvf1",
                       "total": 10737418240,
                       "up": 1048576,

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

@@ -13,6 +13,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "discordMemory": 0,
     "discordRunTime": "",
     "expireDiff": 0,
+    "externalSubUserAgent": "",
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
     "happLinkEnable": false,
@@ -80,6 +81,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHappExcludeApns": false,
     "subHappExcludeRoutes": "",
     "subHappFallbackUrl": "",
+    "subHappLocalProxyAuth": "",
     "subHappNewUrl": "",
     "subHappNoLimit": false,
     "subHappNotificationExpire": false,
@@ -96,8 +98,36 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHappTunMode": "",
     "subHappTunType": "",
     "subHideSettings": false,
+    "subIncyAnnounceUrl": "",
+    "subIncyAppAutoDetect": false,
+    "subIncyBannerBgColor": "",
+    "subIncyBannerButtonColor": "",
+    "subIncyBannerButtonText": "",
+    "subIncyBannerButtonUrl": "",
+    "subIncyBannerText": "",
     "subIncyEnableRouting": false,
+    "subIncyFragmentInterval": "",
+    "subIncyFragmentLength": "",
+    "subIncyFragmentPackets": "",
+    "subIncyFragmentationEnable": "",
+    "subIncyHideCheck": "",
+    "subIncyHideUrl": "",
+    "subIncyNoLimitEnabled": "",
+    "subIncyNoisesDelay": "",
+    "subIncyNoisesEnable": "",
+    "subIncyNoisesPacket": "",
+    "subIncyNoisesType": "",
+    "subIncyPerAppEnable": "",
+    "subIncyPerAppList": "",
+    "subIncyPerAppMode": "",
+    "subIncyPremiumUrl": "",
+    "subIncyProfileDescription": "",
+    "subIncyResolveDnsDomain": "",
+    "subIncyResolveDnsIp": "",
+    "subIncyResolveEnable": "",
     "subIncyRoutingRules": "",
+    "subIncySortOrder": "",
+    "subIncySupportEmail": "",
     "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
@@ -162,6 +192,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "discordMemory": 0,
     "discordRunTime": "",
     "expireDiff": 0,
+    "externalSubUserAgent": "",
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
     "happLinkEnable": false,
@@ -237,6 +268,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHappExcludeApns": false,
     "subHappExcludeRoutes": "",
     "subHappFallbackUrl": "",
+    "subHappLocalProxyAuth": "",
     "subHappNewUrl": "",
     "subHappNoLimit": false,
     "subHappNotificationExpire": false,
@@ -253,8 +285,36 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHappTunMode": "",
     "subHappTunType": "",
     "subHideSettings": false,
+    "subIncyAnnounceUrl": "",
+    "subIncyAppAutoDetect": false,
+    "subIncyBannerBgColor": "",
+    "subIncyBannerButtonColor": "",
+    "subIncyBannerButtonText": "",
+    "subIncyBannerButtonUrl": "",
+    "subIncyBannerText": "",
     "subIncyEnableRouting": false,
+    "subIncyFragmentInterval": "",
+    "subIncyFragmentLength": "",
+    "subIncyFragmentPackets": "",
+    "subIncyFragmentationEnable": "",
+    "subIncyHideCheck": "",
+    "subIncyHideUrl": "",
+    "subIncyNoLimitEnabled": "",
+    "subIncyNoisesDelay": "",
+    "subIncyNoisesEnable": "",
+    "subIncyNoisesPacket": "",
+    "subIncyNoisesType": "",
+    "subIncyPerAppEnable": "",
+    "subIncyPerAppList": "",
+    "subIncyPerAppMode": "",
+    "subIncyPremiumUrl": "",
+    "subIncyProfileDescription": "",
+    "subIncyResolveDnsDomain": "",
+    "subIncyResolveDnsIp": "",
+    "subIncyResolveEnable": "",
     "subIncyRoutingRules": "",
+    "subIncySortOrder": "",
+    "subIncySupportEmail": "",
     "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
@@ -369,6 +429,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "reset": 0,
     "resetDay": 0,
     "resetMax": 0,
+    "resetWeekday": 0,
     "reverse": null,
     "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
     "security": "",
@@ -408,6 +469,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "reset": 0,
         "resetDay": 0,
         "resetMax": 0,
+        "resetWeekday": 0,
         "subId": "abcd1234",
         "totalGB": 53687091200,
         "traffic": null,
@@ -457,6 +519,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "reset": 0,
     "resetDay": 0,
     "resetMax": 0,
+    "resetWeekday": 0,
     "reverse": null,
     "secret": "",
     "security": "",
@@ -468,6 +531,25 @@ export const EXAMPLES: Record<string, unknown> = {
     "updatedAt": 0,
     "uuid": ""
   },
+  "ClientRenewalPreview": {
+    "canRenew": true,
+    "delayedStart": false,
+    "nextExpiry": "2030-02-01T00:00:00Z",
+    "renewAt": "2030-01-01T00:00:00Z",
+    "renewals": 1,
+    "suggestedExpiry": "2030-01-01T00:00:00Z",
+    "suggestedExpiryTime": 1893456000000,
+    "timeZone": "UTC",
+    "validThrough": "2029-12-31T23:59:59Z"
+  },
+  "ClientRenewalPreviewRequest": {
+    "expiryTime": 1893456000000,
+    "reset": 0,
+    "resetCount": 0,
+    "resetDay": 1,
+    "resetMax": 0,
+    "resetWeekday": 0
+  },
   "ClientReverse": {
     "tag": ""
   },
@@ -487,6 +569,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "reset": 0,
     "resetDay": 0,
     "resetMax": 0,
+    "resetWeekday": 0,
     "subId": "abcd1234",
     "totalGB": 53687091200,
     "traffic": null,
@@ -505,6 +588,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "resetCount": 0,
     "resetDay": 0,
     "resetMax": 0,
+    "resetWeekday": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
     "up": 1048576,
@@ -702,6 +786,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "resetCount": 0,
         "resetDay": 0,
         "resetMax": 0,
+        "resetWeekday": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,
         "up": 1048576,

+ 355 - 2
frontend/src/generated/schemas.ts

@@ -43,6 +43,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
+      "externalSubUserAgent": {
+        "type": "string"
+      },
       "externalTrafficInformEnable": {
         "type": "boolean"
       },
@@ -262,6 +265,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHappFallbackUrl": {
         "type": "string"
       },
+      "subHappLocalProxyAuth": {
+        "type": "string"
+      },
       "subHappNewUrl": {
         "type": "string"
       },
@@ -310,12 +316,97 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHideSettings": {
         "type": "boolean"
       },
+      "subIncyAnnounceUrl": {
+        "type": "string"
+      },
+      "subIncyAppAutoDetect": {
+        "description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
+        "type": "boolean"
+      },
+      "subIncyBannerBgColor": {
+        "type": "string"
+      },
+      "subIncyBannerButtonColor": {
+        "type": "string"
+      },
+      "subIncyBannerButtonText": {
+        "type": "string"
+      },
+      "subIncyBannerButtonUrl": {
+        "type": "string"
+      },
+      "subIncyBannerText": {
+        "type": "string"
+      },
       "subIncyEnableRouting": {
         "type": "boolean"
       },
+      "subIncyFragmentInterval": {
+        "type": "string"
+      },
+      "subIncyFragmentLength": {
+        "type": "string"
+      },
+      "subIncyFragmentPackets": {
+        "type": "string"
+      },
+      "subIncyFragmentationEnable": {
+        "type": "string"
+      },
+      "subIncyHideCheck": {
+        "type": "string"
+      },
+      "subIncyHideUrl": {
+        "type": "string"
+      },
+      "subIncyNoLimitEnabled": {
+        "type": "string"
+      },
+      "subIncyNoisesDelay": {
+        "type": "string"
+      },
+      "subIncyNoisesEnable": {
+        "type": "string"
+      },
+      "subIncyNoisesPacket": {
+        "type": "string"
+      },
+      "subIncyNoisesType": {
+        "type": "string"
+      },
+      "subIncyPerAppEnable": {
+        "type": "string"
+      },
+      "subIncyPerAppList": {
+        "type": "string"
+      },
+      "subIncyPerAppMode": {
+        "type": "string"
+      },
+      "subIncyPremiumUrl": {
+        "type": "string"
+      },
+      "subIncyProfileDescription": {
+        "type": "string"
+      },
+      "subIncyResolveDnsDomain": {
+        "type": "string"
+      },
+      "subIncyResolveDnsIp": {
+        "type": "string"
+      },
+      "subIncyResolveEnable": {
+        "type": "string"
+      },
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subIncySortOrder": {
+        "type": "string"
+      },
+      "subIncySupportEmail": {
+        "type": "string"
+      },
       "subInfoNodeEnable": {
         "type": "boolean"
       },
@@ -493,6 +584,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "discordMemory",
       "discordRunTime",
       "expireDiff",
+      "externalSubUserAgent",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
       "happLinkEnable",
@@ -560,6 +652,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHappExcludeApns",
       "subHappExcludeRoutes",
       "subHappFallbackUrl",
+      "subHappLocalProxyAuth",
       "subHappNewUrl",
       "subHappNoLimit",
       "subHappNotificationExpire",
@@ -576,8 +669,36 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHappTunMode",
       "subHappTunType",
       "subHideSettings",
+      "subIncyAnnounceUrl",
+      "subIncyAppAutoDetect",
+      "subIncyBannerBgColor",
+      "subIncyBannerButtonColor",
+      "subIncyBannerButtonText",
+      "subIncyBannerButtonUrl",
+      "subIncyBannerText",
       "subIncyEnableRouting",
+      "subIncyFragmentInterval",
+      "subIncyFragmentLength",
+      "subIncyFragmentPackets",
+      "subIncyFragmentationEnable",
+      "subIncyHideCheck",
+      "subIncyHideUrl",
+      "subIncyNoLimitEnabled",
+      "subIncyNoisesDelay",
+      "subIncyNoisesEnable",
+      "subIncyNoisesPacket",
+      "subIncyNoisesType",
+      "subIncyPerAppEnable",
+      "subIncyPerAppList",
+      "subIncyPerAppMode",
+      "subIncyPremiumUrl",
+      "subIncyProfileDescription",
+      "subIncyResolveDnsDomain",
+      "subIncyResolveDnsIp",
+      "subIncyResolveEnable",
       "subIncyRoutingRules",
+      "subIncySortOrder",
+      "subIncySupportEmail",
       "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
@@ -674,6 +795,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
+      "externalSubUserAgent": {
+        "type": "string"
+      },
       "externalTrafficInformEnable": {
         "type": "boolean"
       },
@@ -917,6 +1041,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHappFallbackUrl": {
         "type": "string"
       },
+      "subHappLocalProxyAuth": {
+        "type": "string"
+      },
       "subHappNewUrl": {
         "type": "string"
       },
@@ -965,12 +1092,97 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHideSettings": {
         "type": "boolean"
       },
+      "subIncyAnnounceUrl": {
+        "type": "string"
+      },
+      "subIncyAppAutoDetect": {
+        "description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
+        "type": "boolean"
+      },
+      "subIncyBannerBgColor": {
+        "type": "string"
+      },
+      "subIncyBannerButtonColor": {
+        "type": "string"
+      },
+      "subIncyBannerButtonText": {
+        "type": "string"
+      },
+      "subIncyBannerButtonUrl": {
+        "type": "string"
+      },
+      "subIncyBannerText": {
+        "type": "string"
+      },
       "subIncyEnableRouting": {
         "type": "boolean"
       },
+      "subIncyFragmentInterval": {
+        "type": "string"
+      },
+      "subIncyFragmentLength": {
+        "type": "string"
+      },
+      "subIncyFragmentPackets": {
+        "type": "string"
+      },
+      "subIncyFragmentationEnable": {
+        "type": "string"
+      },
+      "subIncyHideCheck": {
+        "type": "string"
+      },
+      "subIncyHideUrl": {
+        "type": "string"
+      },
+      "subIncyNoLimitEnabled": {
+        "type": "string"
+      },
+      "subIncyNoisesDelay": {
+        "type": "string"
+      },
+      "subIncyNoisesEnable": {
+        "type": "string"
+      },
+      "subIncyNoisesPacket": {
+        "type": "string"
+      },
+      "subIncyNoisesType": {
+        "type": "string"
+      },
+      "subIncyPerAppEnable": {
+        "type": "string"
+      },
+      "subIncyPerAppList": {
+        "type": "string"
+      },
+      "subIncyPerAppMode": {
+        "type": "string"
+      },
+      "subIncyPremiumUrl": {
+        "type": "string"
+      },
+      "subIncyProfileDescription": {
+        "type": "string"
+      },
+      "subIncyResolveDnsDomain": {
+        "type": "string"
+      },
+      "subIncyResolveDnsIp": {
+        "type": "string"
+      },
+      "subIncyResolveEnable": {
+        "type": "string"
+      },
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subIncySortOrder": {
+        "type": "string"
+      },
+      "subIncySupportEmail": {
+        "type": "string"
+      },
       "subInfoNodeEnable": {
         "type": "boolean"
       },
@@ -1148,6 +1360,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "discordMemory",
       "discordRunTime",
       "expireDiff",
+      "externalSubUserAgent",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
       "happLinkEnable",
@@ -1223,6 +1436,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHappExcludeApns",
       "subHappExcludeRoutes",
       "subHappFallbackUrl",
+      "subHappLocalProxyAuth",
       "subHappNewUrl",
       "subHappNoLimit",
       "subHappNotificationExpire",
@@ -1239,8 +1453,36 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHappTunMode",
       "subHappTunType",
       "subHideSettings",
+      "subIncyAnnounceUrl",
+      "subIncyAppAutoDetect",
+      "subIncyBannerBgColor",
+      "subIncyBannerButtonColor",
+      "subIncyBannerButtonText",
+      "subIncyBannerButtonUrl",
+      "subIncyBannerText",
       "subIncyEnableRouting",
+      "subIncyFragmentInterval",
+      "subIncyFragmentLength",
+      "subIncyFragmentPackets",
+      "subIncyFragmentationEnable",
+      "subIncyHideCheck",
+      "subIncyHideUrl",
+      "subIncyNoLimitEnabled",
+      "subIncyNoisesDelay",
+      "subIncyNoisesEnable",
+      "subIncyNoisesPacket",
+      "subIncyNoisesType",
+      "subIncyPerAppEnable",
+      "subIncyPerAppList",
+      "subIncyPerAppMode",
+      "subIncyPremiumUrl",
+      "subIncyProfileDescription",
+      "subIncyResolveDnsDomain",
+      "subIncyResolveDnsIp",
+      "subIncyResolveEnable",
       "subIncyRoutingRules",
+      "subIncySortOrder",
+      "subIncySupportEmail",
       "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
@@ -1497,13 +1739,17 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "integer"
       },
       "resetDay": {
-        "description": "Calendar renewal day 1-31, 0 = interval mode",
+        "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
         "type": "integer"
       },
       "resetMax": {
         "description": "Max auto-renew count, 0 = unlimited",
         "type": "integer"
       },
+      "resetWeekday": {
+        "description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
+        "type": "integer"
+      },
       "reverse": {
         "allOf": [
           {
@@ -1566,6 +1812,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset",
       "resetDay",
       "resetMax",
+      "resetWeekday",
       "security",
       "subId",
       "tgId",
@@ -1717,6 +1964,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "resetMax": {
         "type": "integer"
       },
+      "resetWeekday": {
+        "type": "integer"
+      },
       "reverse": {},
       "secret": {
         "type": "string"
@@ -1772,6 +2022,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset",
       "resetDay",
       "resetMax",
+      "resetWeekday",
       "reverse",
       "secret",
       "security",
@@ -1785,6 +2036,97 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "ClientRenewalPreview": {
+    "properties": {
+      "canRenew": {
+        "example": true,
+        "type": "boolean"
+      },
+      "delayedStart": {
+        "example": false,
+        "type": "boolean"
+      },
+      "nextExpiry": {
+        "example": "2030-02-01T00:00:00Z",
+        "type": "string"
+      },
+      "renewAt": {
+        "example": "2030-01-01T00:00:00Z",
+        "type": "string"
+      },
+      "renewals": {
+        "example": 1,
+        "type": "integer"
+      },
+      "suggestedExpiry": {
+        "example": "2030-01-01T00:00:00Z",
+        "type": "string"
+      },
+      "suggestedExpiryTime": {
+        "example": 1893456000000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "timeZone": {
+        "example": "UTC",
+        "type": "string"
+      },
+      "validThrough": {
+        "example": "2029-12-31T23:59:59Z",
+        "type": "string"
+      }
+    },
+    "required": [
+      "canRenew",
+      "delayedStart",
+      "nextExpiry",
+      "renewAt",
+      "renewals",
+      "suggestedExpiry",
+      "suggestedExpiryTime",
+      "timeZone",
+      "validThrough"
+    ],
+    "type": "object"
+  },
+  "ClientRenewalPreviewRequest": {
+    "properties": {
+      "expiryTime": {
+        "example": 1893456000000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "reset": {
+        "example": 0,
+        "type": "integer"
+      },
+      "resetCount": {
+        "example": 0,
+        "type": "integer"
+      },
+      "resetDay": {
+        "example": 1,
+        "type": "integer"
+      },
+      "resetMax": {
+        "example": 0,
+        "type": "integer"
+      },
+      "resetWeekday": {
+        "example": 0,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "expiryTime",
+      "reset",
+      "resetCount",
+      "resetDay",
+      "resetMax",
+      "resetWeekday"
+    ],
+    "type": "object"
+  },
   "ClientReverse": {
     "properties": {
       "tag": {
@@ -1855,6 +2197,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 0,
         "type": "integer"
       },
+      "resetWeekday": {
+        "example": 0,
+        "type": "integer"
+      },
       "subId": {
         "example": "abcd1234",
         "type": "string"
@@ -1889,6 +2235,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset",
       "resetDay",
       "resetMax",
+      "resetWeekday",
       "subId",
       "totalGB",
       "updatedAt"
@@ -1944,7 +2291,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "integer"
       },
       "resetDay": {
-        "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+        "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
         "example": 0,
         "type": "integer"
       },
@@ -1953,6 +2300,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 0,
         "type": "integer"
       },
+      "resetWeekday": {
+        "description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
+        "example": 0,
+        "type": "integer"
+      },
       "subId": {
         "example": "i7tvdpeffi0hvvf1",
         "type": "string"
@@ -1985,6 +2337,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "resetCount",
       "resetDay",
       "resetMax",
+      "resetWeekday",
       "subId",
       "total",
       "up",

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

@@ -3,6 +3,7 @@ export type GeoKind = string;
 export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type Protocol = string;
+export type addrFamily = number;
 export type staticEgressResolver = string;
 export type trafficLocalApplyAction = number;
 export type transportBits = number;
@@ -20,6 +21,7 @@ export interface AllSetting {
   discordMemory: number;
   discordRunTime: string;
   expireDiff: number;
+  externalSubUserAgent: string;
   externalTrafficInformEnable: boolean;
   externalTrafficInformURI: string;
   happLinkEnable: boolean;
@@ -87,6 +89,7 @@ export interface AllSetting {
   subHappExcludeApns: boolean;
   subHappExcludeRoutes: string;
   subHappFallbackUrl: string;
+  subHappLocalProxyAuth: string;
   subHappNewUrl: string;
   subHappNoLimit: boolean;
   subHappNotificationExpire: boolean;
@@ -103,8 +106,36 @@ export interface AllSetting {
   subHappTunMode: string;
   subHappTunType: string;
   subHideSettings: boolean;
+  subIncyAnnounceUrl: string;
+  subIncyAppAutoDetect: boolean;
+  subIncyBannerBgColor: string;
+  subIncyBannerButtonColor: string;
+  subIncyBannerButtonText: string;
+  subIncyBannerButtonUrl: string;
+  subIncyBannerText: string;
   subIncyEnableRouting: boolean;
+  subIncyFragmentInterval: string;
+  subIncyFragmentLength: string;
+  subIncyFragmentPackets: string;
+  subIncyFragmentationEnable: string;
+  subIncyHideCheck: string;
+  subIncyHideUrl: string;
+  subIncyNoLimitEnabled: string;
+  subIncyNoisesDelay: string;
+  subIncyNoisesEnable: string;
+  subIncyNoisesPacket: string;
+  subIncyNoisesType: string;
+  subIncyPerAppEnable: string;
+  subIncyPerAppList: string;
+  subIncyPerAppMode: string;
+  subIncyPremiumUrl: string;
+  subIncyProfileDescription: string;
+  subIncyResolveDnsDomain: string;
+  subIncyResolveDnsIp: string;
+  subIncyResolveEnable: string;
   subIncyRoutingRules: string;
+  subIncySortOrder: string;
+  subIncySupportEmail: string;
   subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
@@ -170,6 +201,7 @@ export interface AllSettingView {
   discordMemory: number;
   discordRunTime: string;
   expireDiff: number;
+  externalSubUserAgent: string;
   externalTrafficInformEnable: boolean;
   externalTrafficInformURI: string;
   happLinkEnable: boolean;
@@ -245,6 +277,7 @@ export interface AllSettingView {
   subHappExcludeApns: boolean;
   subHappExcludeRoutes: string;
   subHappFallbackUrl: string;
+  subHappLocalProxyAuth: string;
   subHappNewUrl: string;
   subHappNoLimit: boolean;
   subHappNotificationExpire: boolean;
@@ -261,8 +294,36 @@ export interface AllSettingView {
   subHappTunMode: string;
   subHappTunType: string;
   subHideSettings: boolean;
+  subIncyAnnounceUrl: string;
+  subIncyAppAutoDetect: boolean;
+  subIncyBannerBgColor: string;
+  subIncyBannerButtonColor: string;
+  subIncyBannerButtonText: string;
+  subIncyBannerButtonUrl: string;
+  subIncyBannerText: string;
   subIncyEnableRouting: boolean;
+  subIncyFragmentInterval: string;
+  subIncyFragmentLength: string;
+  subIncyFragmentPackets: string;
+  subIncyFragmentationEnable: string;
+  subIncyHideCheck: string;
+  subIncyHideUrl: string;
+  subIncyNoLimitEnabled: string;
+  subIncyNoisesDelay: string;
+  subIncyNoisesEnable: string;
+  subIncyNoisesPacket: string;
+  subIncyNoisesType: string;
+  subIncyPerAppEnable: string;
+  subIncyPerAppList: string;
+  subIncyPerAppMode: string;
+  subIncyPremiumUrl: string;
+  subIncyProfileDescription: string;
+  subIncyResolveDnsDomain: string;
+  subIncyResolveDnsIp: string;
+  subIncyResolveEnable: string;
   subIncyRoutingRules: string;
+  subIncySortOrder: string;
+  subIncySupportEmail: string;
   subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
@@ -364,6 +425,7 @@ export interface Client {
   reset: number;
   resetDay: number;
   resetMax: number;
+  resetWeekday: number;
   reverse?: ClientReverse | null;
   secret?: string;
   security: string;
@@ -415,6 +477,7 @@ export interface ClientRecord {
   reset: number;
   resetDay: number;
   resetMax: number;
+  resetWeekday: number;
   reverse: unknown;
   secret: string;
   security: string;
@@ -427,6 +490,27 @@ export interface ClientRecord {
   uuid: string;
 }
 
+export interface ClientRenewalPreview {
+  canRenew: boolean;
+  delayedStart: boolean;
+  nextExpiry: string;
+  renewAt: string;
+  renewals: number;
+  suggestedExpiry: string;
+  suggestedExpiryTime: number;
+  timeZone: string;
+  validThrough: string;
+}
+
+export interface ClientRenewalPreviewRequest {
+  expiryTime: number;
+  reset: number;
+  resetCount: number;
+  resetDay: number;
+  resetMax: number;
+  resetWeekday: number;
+}
+
 export interface ClientReverse {
   tag: string;
 }
@@ -444,6 +528,7 @@ export interface ClientSlim {
   reset: number;
   resetDay: number;
   resetMax: number;
+  resetWeekday: number;
   subId: string;
   totalGB: number;
   traffic?: ClientTraffic | null;
@@ -463,6 +548,7 @@ export interface ClientTraffic {
   resetCount: number;
   resetDay: number;
   resetMax: number;
+  resetWeekday: number;
   subId: string;
   total: number;
   up: number;

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

@@ -12,6 +12,9 @@ export type ProcessState = z.infer<typeof ProcessStateSchema>;
 export const ProtocolSchema = z.string();
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
+export const addrFamilySchema = z.number().int();
+export type addrFamily = z.infer<typeof addrFamilySchema>;
+
 export const staticEgressResolverSchema = z.string();
 export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
 
@@ -34,6 +37,7 @@ export const AllSettingSchema = z.object({
   discordMemory: z.number().int().min(0).max(100),
   discordRunTime: z.string(),
   expireDiff: z.number().int().min(0),
+  externalSubUserAgent: z.string(),
   externalTrafficInformEnable: z.boolean(),
   externalTrafficInformURI: z.string(),
   happLinkEnable: z.boolean(),
@@ -101,6 +105,7 @@ export const AllSettingSchema = z.object({
   subHappExcludeApns: z.boolean(),
   subHappExcludeRoutes: z.string(),
   subHappFallbackUrl: z.string(),
+  subHappLocalProxyAuth: z.string(),
   subHappNewUrl: z.string(),
   subHappNoLimit: z.boolean(),
   subHappNotificationExpire: z.boolean(),
@@ -117,8 +122,36 @@ export const AllSettingSchema = z.object({
   subHappTunMode: z.string(),
   subHappTunType: z.string(),
   subHideSettings: z.boolean(),
+  subIncyAnnounceUrl: z.string(),
+  subIncyAppAutoDetect: z.boolean(),
+  subIncyBannerBgColor: z.string(),
+  subIncyBannerButtonColor: z.string(),
+  subIncyBannerButtonText: z.string(),
+  subIncyBannerButtonUrl: z.string(),
+  subIncyBannerText: z.string(),
   subIncyEnableRouting: z.boolean(),
+  subIncyFragmentInterval: z.string(),
+  subIncyFragmentLength: z.string(),
+  subIncyFragmentPackets: z.string(),
+  subIncyFragmentationEnable: z.string(),
+  subIncyHideCheck: z.string(),
+  subIncyHideUrl: z.string(),
+  subIncyNoLimitEnabled: z.string(),
+  subIncyNoisesDelay: z.string(),
+  subIncyNoisesEnable: z.string(),
+  subIncyNoisesPacket: z.string(),
+  subIncyNoisesType: z.string(),
+  subIncyPerAppEnable: z.string(),
+  subIncyPerAppList: z.string(),
+  subIncyPerAppMode: z.string(),
+  subIncyPremiumUrl: z.string(),
+  subIncyProfileDescription: z.string(),
+  subIncyResolveDnsDomain: z.string(),
+  subIncyResolveDnsIp: z.string(),
+  subIncyResolveEnable: z.string(),
   subIncyRoutingRules: z.string(),
+  subIncySortOrder: z.string(),
+  subIncySupportEmail: z.string(),
   subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
@@ -185,6 +218,7 @@ export const AllSettingViewSchema = z.object({
   discordMemory: z.number().int().min(0).max(100),
   discordRunTime: z.string(),
   expireDiff: z.number().int().min(0),
+  externalSubUserAgent: z.string(),
   externalTrafficInformEnable: z.boolean(),
   externalTrafficInformURI: z.string(),
   happLinkEnable: z.boolean(),
@@ -260,6 +294,7 @@ export const AllSettingViewSchema = z.object({
   subHappExcludeApns: z.boolean(),
   subHappExcludeRoutes: z.string(),
   subHappFallbackUrl: z.string(),
+  subHappLocalProxyAuth: z.string(),
   subHappNewUrl: z.string(),
   subHappNoLimit: z.boolean(),
   subHappNotificationExpire: z.boolean(),
@@ -276,8 +311,36 @@ export const AllSettingViewSchema = z.object({
   subHappTunMode: z.string(),
   subHappTunType: z.string(),
   subHideSettings: z.boolean(),
+  subIncyAnnounceUrl: z.string(),
+  subIncyAppAutoDetect: z.boolean(),
+  subIncyBannerBgColor: z.string(),
+  subIncyBannerButtonColor: z.string(),
+  subIncyBannerButtonText: z.string(),
+  subIncyBannerButtonUrl: z.string(),
+  subIncyBannerText: z.string(),
   subIncyEnableRouting: z.boolean(),
+  subIncyFragmentInterval: z.string(),
+  subIncyFragmentLength: z.string(),
+  subIncyFragmentPackets: z.string(),
+  subIncyFragmentationEnable: z.string(),
+  subIncyHideCheck: z.string(),
+  subIncyHideUrl: z.string(),
+  subIncyNoLimitEnabled: z.string(),
+  subIncyNoisesDelay: z.string(),
+  subIncyNoisesEnable: z.string(),
+  subIncyNoisesPacket: z.string(),
+  subIncyNoisesType: z.string(),
+  subIncyPerAppEnable: z.string(),
+  subIncyPerAppList: z.string(),
+  subIncyPerAppMode: z.string(),
+  subIncyPremiumUrl: z.string(),
+  subIncyProfileDescription: z.string(),
+  subIncyResolveDnsDomain: z.string(),
+  subIncyResolveDnsIp: z.string(),
+  subIncyResolveEnable: z.string(),
   subIncyRoutingRules: z.string(),
+  subIncySortOrder: z.string(),
+  subIncySupportEmail: z.string(),
   subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
@@ -383,6 +446,7 @@ export const ClientSchema = z.object({
   reset: z.number().int(),
   resetDay: z.number().int(),
   resetMax: z.number().int(),
+  resetWeekday: z.number().int(),
   reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
   secret: z.string().optional(),
   security: z.string(),
@@ -437,6 +501,7 @@ export const ClientRecordSchema = z.object({
   reset: z.number().int(),
   resetDay: z.number().int(),
   resetMax: z.number().int(),
+  resetWeekday: z.number().int(),
   reverse: z.unknown(),
   secret: z.string(),
   security: z.string(),
@@ -450,6 +515,29 @@ export const ClientRecordSchema = z.object({
 });
 export type ClientRecord = z.infer<typeof ClientRecordSchema>;
 
+export const ClientRenewalPreviewSchema = z.object({
+  canRenew: z.boolean(),
+  delayedStart: z.boolean(),
+  nextExpiry: z.string(),
+  renewAt: z.string(),
+  renewals: z.number().int(),
+  suggestedExpiry: z.string(),
+  suggestedExpiryTime: z.number().int(),
+  timeZone: z.string(),
+  validThrough: z.string(),
+});
+export type ClientRenewalPreview = z.infer<typeof ClientRenewalPreviewSchema>;
+
+export const ClientRenewalPreviewRequestSchema = z.object({
+  expiryTime: z.number().int(),
+  reset: z.number().int(),
+  resetCount: z.number().int(),
+  resetDay: z.number().int(),
+  resetMax: z.number().int(),
+  resetWeekday: z.number().int(),
+});
+export type ClientRenewalPreviewRequest = z.infer<typeof ClientRenewalPreviewRequestSchema>;
+
 export const ClientReverseSchema = z.object({
   tag: z.string(),
 });
@@ -468,6 +556,7 @@ export const ClientSlimSchema = z.object({
   reset: z.number().int(),
   resetDay: z.number().int(),
   resetMax: z.number().int(),
+  resetWeekday: z.number().int(),
   subId: z.string(),
   totalGB: z.number().int(),
   traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
@@ -488,6 +577,7 @@ export const ClientTrafficSchema = z.object({
   resetCount: z.number().int(),
   resetDay: z.number().int(),
   resetMax: z.number().int(),
+  resetWeekday: z.number().int(),
   subId: z.string(),
   total: z.number().int(),
   up: z.number().int(),

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

@@ -696,6 +696,7 @@ export function useClients(options: UseClientsOptions = {}) {
         tgId: Number(base.tgId) || 0,
         reset: Number(base.reset) || 0,
         resetDay: Number(base.resetDay) || 0,
+        resetWeekday: Number(base.resetWeekday) || 0,
         resetMax: Number(base.resetMax) || 0,
         trafficReset: base.trafficReset || 'never',
         trafficResetDay: Number(base.trafficResetDay) || 1,

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

@@ -66,6 +66,7 @@ export class AllSetting {
   restartXrayOnClientDisable = true;
   subCertFile = '';
   subKeyFile = '';
+  externalSubUserAgent = 'v2rayNG/1.8.5';
   subUpdates = 12;
   subEncrypt = true;
   subURI = '';
@@ -104,6 +105,36 @@ export class AllSetting {
   subHappAutoConnectType = 'lowestdelay';
   subHappPerAppMode = 'off';
   subHappPerAppList = '';
+  subHappLocalProxyAuth = 'auto';
+
+  subIncyAppAutoDetect = false;
+  subIncyProfileDescription = '';
+  subIncySortOrder = '';
+  subIncySupportEmail = '';
+  subIncyAnnounceUrl = '';
+  subIncyPremiumUrl = '';
+  subIncyBannerText = '';
+  subIncyBannerButtonText = '';
+  subIncyBannerButtonUrl = '';
+  subIncyBannerBgColor = '';
+  subIncyBannerButtonColor = '';
+  subIncyHideUrl = '';
+  subIncyHideCheck = '';
+  subIncyNoLimitEnabled = '';
+  subIncyPerAppEnable = '';
+  subIncyPerAppMode = '';
+  subIncyPerAppList = '';
+  subIncyFragmentationEnable = '';
+  subIncyFragmentLength = '';
+  subIncyFragmentInterval = '';
+  subIncyFragmentPackets = '';
+  subIncyNoisesEnable = '';
+  subIncyNoisesType = '';
+  subIncyNoisesPacket = '';
+  subIncyNoisesDelay = '';
+  subIncyResolveEnable = '';
+  subIncyResolveDnsDomain = '';
+  subIncyResolveDnsIp = '';
 
   timeLocation = 'Local';
 

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

@@ -1169,6 +1169,52 @@ export const sections: readonly Section[] = [
         body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "limitHwid": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
         response: '{\n  "success": true,\n  "msg": "Client added"\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/renewalPreview',
+        summary: 'Preview client auto-renewal dates without saving or resetting anything.',
+        description:
+          'Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.',
+        params: [
+          {
+            name: 'expiryTime',
+            in: 'body (json)',
+            type: 'integer',
+            desc: 'Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration.',
+          },
+          {
+            name: 'reset',
+            in: 'body (json)',
+            type: 'integer',
+            desc: 'Fixed interval in days; 0 disabled.',
+          },
+          {
+            name: 'resetDay',
+            in: 'body (json)',
+            type: 'integer',
+            desc: 'Monthly calendar day 1-31; 0 disabled.',
+          },
+          {
+            name: 'resetWeekday',
+            in: 'body (json)',
+            type: 'integer',
+            desc: 'Weekly calendar day 1-7 (Monday-Sunday); 0 disabled.',
+          },
+          {
+            name: 'resetMax',
+            in: 'body (json)',
+            type: 'integer',
+            desc: 'Maximum renewals; 0 unlimited.',
+          },
+          {
+            name: 'resetCount',
+            in: 'body (json)',
+            type: 'integer',
+            desc: 'Renewals already consumed; defaults to 0.',
+          },
+        ],
+        responseSchema: 'ClientRenewalPreview',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/update/:email',
@@ -1283,15 +1329,15 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/clients/export',
         summary:
-          'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
+          'Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "limitHwid": 2,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "limitHwid": 2,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9],\n      "traffic": {\n        "up": 1048576,\n        "down": 2097152,\n        "resetCount": 0,\n        "lastOnline": 1735680000000\n      }\n    }\n  ]\n}',
       },
       {
         method: 'POST',
         path: '/panel/api/clients/import',
         summary:
-          'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.',
+          'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.',
         body: '{\n  "data": "[{\\"client\\":{\\"email\\":\\"[email protected]\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
         response:
           '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use: [email protected]" }\n    ]\n  }\n}',

+ 10 - 26
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -25,6 +25,7 @@ import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { useClients, type InboundOption } from '@/hooks/useClients';
 import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
+import ClientRenewalFields from './ClientRenewalFields';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -57,6 +58,7 @@ const EMPTY: ClientBulkAddFormValues = {
   expiryTime: 0,
   reset: 0,
   resetDay: 0,
+  resetWeekday: 0,
   resetMax: 0,
   trafficReset: 'never' as const,
   trafficResetDay: 1,
@@ -215,6 +217,7 @@ export default function ClientBulkAddModal({
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
           resetDay: Number(current.resetDay) || 0,
+          resetWeekday: Number(current.resetWeekday) || 0,
           resetMax: Number(current.resetMax) || 0,
           trafficReset: current.trafficReset || 'never',
           trafficResetDay: Number(current.trafficResetDay) || 1,
@@ -437,32 +440,13 @@ export default function ClientBulkAddModal({
               </Form.Item>
             )}
 
-            <FormField
-              name="reset"
-              label={t('pages.clients.renew')}
-              tooltip={t('pages.clients.renewDesc')}
-              transform={{ output: (v) => Number(v) || 0 }}
-            >
-              <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')}
-              tooltip={t('pages.clients.renewMaxDesc')}
-              transform={{ output: (v) => Number(v) || 0 }}
-            >
-              <InputNumber min={0} />
-            </FormField>
+            <ClientRenewalFields
+              active={open}
+              delayedStart={delayedStart}
+              expiryTime={expiryTime}
+              bulk
+              setExpiry={(expiry) => methods.setValue('expiryTime', expiry)}
+            />
 
             <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
               <Select

+ 16 - 29
frontend/src/pages/clients/ClientFormModal.tsx

@@ -48,6 +48,7 @@ import type {
   ExternalLinkInput,
 } from '@/hooks/useClients';
 import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
+import ClientRenewalFields from './ClientRenewalFields';
 import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
 import './ClientFormModal.css';
 
@@ -155,6 +156,7 @@ const EMPTY: Values = {
   delayedDays: 0,
   reset: 0,
   resetDay: 0,
+  resetWeekday: 0,
   resetMax: 0,
   trafficReset: 'never' as const,
   trafficResetDay: 1,
@@ -259,6 +261,7 @@ export default function ClientFormModal({
   const methods = useForm<Values>({ defaultValues: EMPTY });
   const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
   const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
+  const delayedDays = useWatch({ control: methods.control, name: 'delayedDays' });
   const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
   const enable = useWatch({ control: methods.control, name: 'enable' });
   const flow = useWatch({ control: methods.control, name: 'flow' });
@@ -366,6 +369,7 @@ export default function ClientFormModal({
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
         resetDay: Number(client.resetDay) || 0,
+        resetWeekday: Number(client.resetWeekday) || 0,
         resetMax: Number(client.resetMax) || 0,
         trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
         trafficResetDay: Number(client.trafficResetDay) || 1,
@@ -663,6 +667,7 @@ export default function ClientFormModal({
       delayedDays: values.delayedDays,
       reset: values.reset,
       resetDay: values.resetDay,
+      resetWeekday: values.resetWeekday,
       resetMax: values.resetMax,
       trafficReset: values.trafficReset,
       trafficResetDay: values.trafficResetDay,
@@ -696,6 +701,7 @@ export default function ClientFormModal({
       expiryTime,
       reset: Number(values.reset) || 0,
       resetDay: Number(values.resetDay) || 0,
+      resetWeekday: Number(values.resetWeekday) || 0,
       resetMax: Number(values.resetMax) || 0,
       trafficReset: values.trafficReset || 'never',
       trafficResetDay: Number(values.trafficResetDay) || 1,
@@ -985,35 +991,16 @@ export default function ClientFormModal({
                             />
                           </Form.Item>
                         </Col>
-                        <Col xs={12} md={6}>
-                          <FormField
-                            name="reset"
-                            label={t('pages.clients.renewDays')}
-                            tooltip={t('pages.clients.renewDesc')}
-                            transform={{ output: (v) => Number(v) || 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"
-                            label={t('pages.clients.renewMax')}
-                            tooltip={t('pages.clients.renewMaxDesc')}
-                            transform={{ output: (v) => Number(v) || 0 }}
-                          >
-                            <InputNumber min={0} style={{ width: '100%' }} />
-                          </FormField>
+                        <Col xs={24}>
+                          <ClientRenewalFields
+                            active={open}
+                            delayedStart={delayedStart}
+                            expiryTime={
+                              delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
+                            }
+                            resetCount={client?.traffic?.resetCount || 0}
+                            setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
+                          />
                         </Col>
                         <Col xs={12} md={6}>
                           <FormField

+ 197 - 0
frontend/src/pages/clients/ClientRenewalFields.tsx

@@ -0,0 +1,197 @@
+import { useEffect, useId, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useFormContext, useWatch } from 'react-hook-form';
+import { useQuery } from '@tanstack/react-query';
+import { Button, Form, InputNumber, Select, Space, Typography } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
+import { ClientRenewalPreviewSchema } from '@/generated/zod';
+import { HttpUtil } from '@/utils';
+import type { ClientFormValues } from '@/schemas/client';
+
+type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
+type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
+
+export default function ClientRenewalFields({
+  active,
+  expiryTime,
+  resetCount = 0,
+  bulk = false,
+  delayedStart = false,
+  setExpiry,
+}: {
+  active: boolean;
+  expiryTime: number;
+  resetCount?: number;
+  bulk?: boolean;
+  delayedStart?: boolean;
+  setExpiry: (expiry: number) => void;
+}) {
+  const { t, i18n } = useTranslation();
+  const formId = useId();
+  const modeId = 'client-renewal-mode-' + formId;
+  const { control, setValue } = useFormContext<RenewalFields>();
+  const [reset, resetDay, resetWeekday, resetMax] = useWatch({
+    control,
+    name: ['reset', 'resetDay', 'resetWeekday', 'resetMax'],
+  });
+  const mode: RenewalMode =
+    resetDay > 0 ? 'monthly' : resetWeekday > 0 ? 'weekly' : reset > 0 ? 'interval' : 'none';
+  const request = useMemo(
+    () => ({
+      expiryTime,
+      reset: reset || 0,
+      resetDay: resetDay || 0,
+      resetWeekday: resetWeekday || 0,
+      resetMax: resetMax || 0,
+      resetCount,
+    }),
+    [expiryTime, reset, resetDay, resetWeekday, resetMax, resetCount],
+  );
+  const [debounced, setDebounced] = useState(request);
+  useEffect(() => {
+    const timer = setTimeout(() => setDebounced(request), 250);
+    return () => clearTimeout(timer);
+  }, [request]);
+  const query = useQuery({
+    queryKey: ['clients', 'renewalPreview', debounced],
+    enabled: active && mode !== 'none' && request === debounced,
+    retry: false,
+    queryFn: async () => {
+      const msg = await HttpUtil.post('/panel/api/clients/renewalPreview', debounced, {
+        headers: { 'Content-Type': 'application/json' },
+        silent: true,
+      });
+      if (!msg?.success) throw new Error(msg?.msg || 'Renewal preview failed');
+      return ClientRenewalPreviewSchema.parse(msg.obj);
+    },
+  });
+  const preview = request === debounced ? query.data : undefined;
+  const weekdayFormatter = new Intl.DateTimeFormat(i18n.language, {
+    weekday: 'long',
+    timeZone: 'UTC',
+  });
+  function changeMode(next: RenewalMode) {
+    setValue('reset', next === 'interval' ? Math.max(1, reset || 0) : 0);
+    setValue('resetDay', next === 'monthly' ? Math.max(1, resetDay || 0) : 0);
+    setValue('resetWeekday', next === 'weekly' ? Math.max(1, resetWeekday || 0) : 0);
+  }
+  return (
+    <>
+      <Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
+        <Select
+          id={modeId}
+          value={mode}
+          onChange={changeMode}
+          options={[
+            { value: 'none', label: t('pages.clients.renewModeNone') },
+            { value: 'interval', label: t('pages.clients.renewModeInterval') },
+            { value: 'weekly', label: t('pages.clients.renewModeWeekly') },
+            { value: 'monthly', label: t('pages.clients.renewModeMonthly') },
+          ]}
+        />
+      </Form.Item>
+      {mode === 'interval' && (
+        <FormField
+          name="reset"
+          label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
+          tooltip={t('pages.clients.renewDesc')}
+          transform={{ output: (v) => Number(v) || 1 }}
+        >
+          <InputNumber id={'client-renewal-interval-' + formId} min={1} style={{ width: '100%' }} />
+        </FormField>
+      )}
+      {mode === 'monthly' && (
+        <FormField
+          name="resetDay"
+          label={t('pages.clients.renewOnDay')}
+          tooltip={t('pages.clients.renewOnDayDesc')}
+          transform={{ output: (v) => Number(v) || 1 }}
+        >
+          <InputNumber
+            id={'client-renewal-day-' + formId}
+            min={1}
+            max={31}
+            style={{ width: '100%' }}
+          />
+        </FormField>
+      )}
+      {mode === 'weekly' && (
+        <FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
+          <Select
+            id={'client-renewal-weekday-' + formId}
+            options={Array.from({ length: 7 }, (_, i) => ({
+              value: i + 1,
+              label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
+            }))}
+          />
+        </FormField>
+      )}
+      {mode !== 'none' && (
+        <>
+          <FormField
+            name="resetMax"
+            label={t('pages.clients.renewMax')}
+            tooltip={t('pages.clients.renewMaxDesc')}
+            transform={{ output: (v) => Number(v) || 0 }}
+          >
+            <InputNumber min={0} style={{ width: '100%' }} />
+          </FormField>
+          <Typography.Paragraph type="secondary">
+            {t('pages.clients.renewScheduleDesc')}
+          </Typography.Paragraph>
+          {query.isError && request === debounced && (
+            <Typography.Paragraph type="warning">
+              {t('pages.clients.renewPreviewError')}
+            </Typography.Paragraph>
+          )}
+          {preview && (
+            <Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
+              <Typography.Text>
+                {t('pages.clients.renewPreview', { zone: preview.timeZone })}
+              </Typography.Text>
+              {delayedStart || preview.delayedStart ? (
+                <Typography.Text type="secondary">
+                  {t('pages.clients.renewFirstUse')}
+                </Typography.Text>
+              ) : expiryTime === 0 ? (
+                <>
+                  <Typography.Text type="warning">
+                    {t('pages.clients.renewNeedsExpiry')}
+                  </Typography.Text>
+                  {preview.suggestedExpiryTime > 0 && (
+                    <Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
+                      {t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
+                    </Button>
+                  )}
+                </>
+              ) : (
+                <>
+                  <Typography.Text>
+                    {t('pages.clients.renewAt')}: {preview.renewAt}
+                  </Typography.Text>
+                  <Typography.Text>
+                    {t('pages.clients.renewValidThrough')}: {preview.validThrough}
+                  </Typography.Text>
+                  {preview.nextExpiry && (
+                    <Typography.Text>
+                      {t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
+                    </Typography.Text>
+                  )}
+                  <Typography.Text>
+                    {t('pages.clients.renewPeriods', { count: preview.renewals })}
+                  </Typography.Text>
+                  {!preview.canRenew && (
+                    <Typography.Text type="warning">
+                      {t('pages.clients.renewUnavailable')}
+                    </Typography.Text>
+                  )}
+                </>
+              )}
+            </Space>
+          )}
+        </>
+      )}
+    </>
+  );
+}

+ 3 - 3
frontend/src/pages/inbounds/form/protocols/amneziawg.tsx

@@ -103,13 +103,13 @@ export default function AmneziawgFields({
         <InputNumber min={0} style={{ width: '100%' }} />
       </FormField>
       <FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
-        <InputNumber min={0} style={{ width: '100%' }} />
+        <InputNumber min={0} max={1552} style={{ width: '100%' }} />
       </FormField>
       <FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
-        <InputNumber min={0} style={{ width: '100%' }} />
+        <InputNumber min={0} max={1608} style={{ width: '100%' }} />
       </FormField>
       <FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
-        <InputNumber min={0} max={64} style={{ width: '100%' }} />
+        <InputNumber min={0} max={1636} style={{ width: '100%' }} />
       </FormField>
       <FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
         <InputNumber min={0} max={32} style={{ width: '100%' }} />

+ 204 - 0
frontend/src/pages/settings/HappRoutingEditorModal.tsx

@@ -0,0 +1,204 @@
+import { useId, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Input, Modal, Space, Tabs, Typography } from 'antd';
+
+import JsonEditor from '@/components/form/JsonEditor';
+import type { HappRoutingProfile } from '@/schemas/happRouting';
+import {
+  buildHappRoutingDeeplink,
+  loadHappRouting,
+  parseHappRoutingJson,
+  parseHappRoutingList,
+  type HappRoutingListKey,
+} from './happRoutingEditor';
+
+interface HappRoutingEditorModalProps {
+  input: string;
+  onCancel: () => void;
+  onGenerate: (deeplink: string) => void;
+}
+
+const basicFields: { key: HappRoutingListKey; label: string; placeholder: string }[] = [
+  {
+    key: 'DirectSites',
+    label: 'subHappDirectDomains',
+    placeholder: 'domain:ir\ndomain:cn\nexample.local',
+  },
+  {
+    key: 'ProxySites',
+    label: 'subHappProxyDomains',
+    placeholder: 'geosite:google\nyoutube.com',
+  },
+  {
+    key: 'BlockSites',
+    label: 'subHappBlockDomains',
+    placeholder: 'geosite:category-ads-all\nanalytics.google.com',
+  },
+  {
+    key: 'DirectIp',
+    label: 'subHappDirectIPs',
+    placeholder: 'geoip:ir\n192.168.0.0/16\n10.0.0.0/8',
+  },
+  {
+    key: 'ProxyIp',
+    label: 'subHappProxyIPs',
+    placeholder: '1.1.1.1/32\n8.8.8.8/32',
+  },
+  {
+    key: 'BlockIp',
+    label: 'subHappBlockIPs',
+    placeholder: 'geoip:phishing\n0.0.0.0/8',
+  },
+];
+
+type BasicBuffers = Record<HappRoutingListKey, string>;
+
+function basicBuffers(profile: HappRoutingProfile | null): BasicBuffers {
+  return {
+    DirectSites: profile?.DirectSites?.join('\n') ?? '',
+    ProxySites: profile?.ProxySites?.join('\n') ?? '',
+    BlockSites: profile?.BlockSites?.join('\n') ?? '',
+    DirectIp: profile?.DirectIp?.join('\n') ?? '',
+    ProxyIp: profile?.ProxyIp?.join('\n') ?? '',
+    BlockIp: profile?.BlockIp?.join('\n') ?? '',
+  };
+}
+
+function mergeBasicRules(profile: HappRoutingProfile, buffers: BasicBuffers): HappRoutingProfile {
+  const next = { ...profile };
+  // Only replace edited lists; absent lists and all other profile fields must survive unchanged.
+  for (const { key } of basicFields) {
+    if (buffers[key] !== (profile[key]?.join('\n') ?? '')) {
+      next[key] = parseHappRoutingList(buffers[key]);
+    }
+  }
+  return next;
+}
+
+const loadErrorKeys = {
+  off: 'subHappEditorLoadOff',
+  remote: 'subHappEditorLoadRemote',
+  invalid: 'subHappEditorLoadInvalid',
+};
+
+export default function HappRoutingEditorModal({
+  input,
+  onCancel,
+  onGenerate,
+}: HappRoutingEditorModalProps) {
+  const { t } = useTranslation();
+  const fieldId = useId();
+  const [loaded] = useState(() => loadHappRouting(input));
+  const [profile, setProfile] = useState(() => (loaded.success ? loaded.profile : null));
+  const [activeTab, setActiveTab] = useState('basic');
+  // Keep raw buffers while typing so trailing newlines and temporarily invalid JSON are not lost.
+  const [buffers, setBuffers] = useState(() => basicBuffers(profile));
+  const [jsonText, setJsonText] = useState(() => (profile ? JSON.stringify(profile, null, 2) : ''));
+  const advancedProfile = activeTab === 'advanced' ? parseHappRoutingJson(jsonText) : null;
+  const invalidJson = activeTab === 'advanced' && advancedProfile === null;
+
+  const switchTab = (nextTab: string) => {
+    if (!profile || nextTab === activeTab) return;
+    if (nextTab === 'advanced') {
+      const next = mergeBasicRules(profile, buffers);
+      setProfile(next);
+      setJsonText(JSON.stringify(next, null, 2));
+    } else {
+      if (!advancedProfile) return;
+      setProfile(advancedProfile);
+      setBuffers(basicBuffers(advancedProfile));
+    }
+    setActiveTab(nextTab);
+  };
+
+  const generate = () => {
+    if (!loaded.success || !profile) return;
+    const next = activeTab === 'advanced' ? advancedProfile : mergeBasicRules(profile, buffers);
+    if (next) onGenerate(buildHappRoutingDeeplink(next, loaded.mode));
+  };
+
+  return (
+    <Modal
+      title={t('pages.settings.subHappModalTitle')}
+      open
+      onCancel={onCancel}
+      onOk={generate}
+      okText={t('pages.settings.subHappBuildDeeplink')}
+      okButtonProps={{ disabled: !loaded.success || invalidJson }}
+      width={650}
+    >
+      <Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
+        {!loaded.success ? (
+          <Alert type="error" showIcon title={t(`pages.settings.${loadErrorKeys[loaded.error]}`)} />
+        ) : loaded.isNew ? (
+          <Alert type="info" showIcon title={t('pages.settings.subHappEditorNew')} />
+        ) : null}
+        {loaded.success ? (
+          <Tabs
+            activeKey={activeTab}
+            onChange={switchTab}
+            destroyOnHidden
+            items={[
+              {
+                key: 'basic',
+                label: t('pages.settings.subHappEditorBasic'),
+                disabled: invalidJson,
+                children: (
+                  <Space orientation="vertical" style={{ width: '100%' }} size="middle">
+                    <Typography.Text type="secondary" id={`${fieldId}-hint`}>
+                      {t('pages.settings.subHappEditorListHint')}
+                    </Typography.Text>
+                    {basicFields.map(({ key, label, placeholder }) => (
+                      <div key={key}>
+                        <label
+                          htmlFor={`${fieldId}-${key}`}
+                          style={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
+                        >
+                          {t(`pages.settings.${label}`)}
+                        </label>
+                        <Input.TextArea
+                          id={`${fieldId}-${key}`}
+                          aria-describedby={`${fieldId}-hint`}
+                          rows={2}
+                          value={buffers[key]}
+                          placeholder={placeholder}
+                          onChange={(event) =>
+                            setBuffers((previous) => ({ ...previous, [key]: event.target.value }))
+                          }
+                        />
+                      </div>
+                    ))}
+                  </Space>
+                ),
+              },
+              {
+                key: 'advanced',
+                label: t('pages.settings.subHappEditorAdvanced'),
+                children: (
+                  <Space orientation="vertical" style={{ width: '100%' }} size="middle">
+                    <Typography.Text type="secondary">
+                      {t('pages.settings.subHappEditorAdvancedHint')}
+                    </Typography.Text>
+                    {invalidJson ? (
+                      <Alert
+                        type="error"
+                        showIcon
+                        title={t('pages.settings.subHappEditorInvalidJson')}
+                      />
+                    ) : null}
+                    <JsonEditor
+                      value={jsonText}
+                      onChange={setJsonText}
+                      minHeight="320px"
+                      maxHeight="50vh"
+                    />
+                  </Space>
+                ),
+              },
+            ]}
+          />
+        ) : null}
+      </Space>
+    </Modal>
+  );
+}

+ 45 - 104
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd';
+import { Button, Input, Select, Space, Switch, Tabs, message } from 'antd';
 import {
   BranchesOutlined,
   BuildOutlined,
@@ -13,7 +13,8 @@ import {
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
-import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
+import { buildHappPresetDeeplink } from './happPresets';
+import HappRoutingEditorModal from './HappRoutingEditorModal';
 import { catTabLabel } from './catTabLabel';
 
 interface HappSettingsContentProps {
@@ -32,38 +33,20 @@ export default function HappSettingsContent({
   defaultActiveTab = 'routing',
 }: HappSettingsContentProps) {
   const { t } = useTranslation();
+  // Generator choices stay local until Apply updates the draft; page Save persists it.
   const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
+  const [includeAdblock, setIncludeAdblock] = useState(false);
   const [isModalOpen, setIsModalOpen] = useState(false);
 
-  const [directDomains, setDirectDomains] = useState('');
-  const [proxyDomains, setProxyDomains] = useState('');
-  const [blockDomains, setBlockDomains] = useState('');
-  const [directIPs, setDirectIPs] = useState('');
-  const [proxyIPs, setProxyIPs] = useState('');
-  const [blockIPs, setBlockIPs] = useState('');
-
   const applyPreset = () => {
-    const payload = buildHappPresetDeeplink(selectedPreset);
+    const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
     if (payload) {
       updateSetting({ subRoutingRules: payload });
       message.success(t('pages.settings.subHappPresetApplied'));
     }
   };
 
-  const handleBuildDeeplink = () => {
-    const profile = {
-      Name: 'Custom Rules',
-      GlobalProxy: 'true',
-      DirectSites: parseList(directDomains),
-      DirectIp: parseList(directIPs),
-      ProxySites: parseList(proxyDomains),
-      ProxyIp: parseList(proxyIPs),
-      BlockSites: parseList(blockDomains),
-      BlockIp: parseList(blockIPs),
-      DomainStrategy: 'IPIfNonMatch',
-    };
-
-    const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
+  const handleBuildDeeplink = (deeplink: string) => {
     updateSetting({ subRoutingRules: deeplink });
     setIsModalOpen(false);
     message.success(t('pages.settings.subHappDeeplinkGenerated'));
@@ -112,21 +95,31 @@ export default function HappSettingsContent({
                   title={t('pages.settings.subHappPresets')}
                   description={t('pages.settings.subHappPresetsDesc')}
                 >
-                  <Space orientation="horizontal" style={{ width: '100%' }}>
+                  <Space orientation="horizontal" wrap style={{ width: '100%' }}>
                     <Select
+                      aria-label={t('pages.settings.subHappPresets')}
                       value={selectedPreset}
                       style={{ minWidth: 170 }}
                       onChange={setSelectedPreset}
                       options={[
                         { value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
                         { value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
-                        { value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
                         { value: 'global', label: t('pages.settings.subHappPresetGlobal') },
+                        { value: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
                         { value: 'off', label: t('pages.settings.subHappPresetOff') },
                       ]}
                     />
+                    <Space size="small">
+                      <Switch
+                        aria-label={t('pages.settings.subHappIncludeAdblock')}
+                        checked={includeAdblock}
+                        disabled={selectedPreset === 'off'}
+                        onChange={setIncludeAdblock}
+                      />
+                      <span>{t('pages.settings.subHappIncludeAdblock')}</span>
+                    </Space>
                     <Button type="primary" onClick={applyPreset}>
-                      {t('pages.settings.subHappPresets')}
+                      {t('pages.settings.subHappApplyPreset')}
                     </Button>
                   </Space>
                 </SettingListItem>
@@ -382,6 +375,23 @@ export default function HappSettingsContent({
                   />
                 </SettingListItem>
 
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappLocalProxyAuth')}
+                  description={t('pages.settings.subHappLocalProxyAuthDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappLocalProxyAuth ?? 'auto'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappLocalProxyAuth: v })}
+                    options={[
+                      { value: 'auto', label: t('pages.settings.subHappLocalProxyAuthAuto') },
+                      { value: 'disable', label: t('pages.settings.subHappLocalProxyAuthDisable') },
+                      { value: '', label: t('pages.settings.subHappLocalProxyAuthUnset') },
+                    ]}
+                  />
+                </SettingListItem>
+
                 <SettingListItem
                   paddings="small"
                   title={t('pages.settings.subHappAutoConnect')}
@@ -572,83 +582,14 @@ export default function HappSettingsContent({
         ]}
       />
 
-      <Modal
-        title={t('pages.settings.subHappModalTitle')}
-        open={isModalOpen}
-        onCancel={() => setIsModalOpen(false)}
-        onOk={handleBuildDeeplink}
-        okText={t('pages.settings.subHappBuildDeeplink')}
-        width={650}
-      >
-        <Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
-          <div>
-            <div style={{ fontWeight: 600, marginBottom: 4 }}>
-              {t('pages.settings.subHappDirectDomains')}
-            </div>
-            <Input.TextArea
-              rows={2}
-              value={directDomains}
-              placeholder="domain:ir, domain:cn, example.local"
-              onChange={(e) => setDirectDomains(e.target.value)}
-            />
-          </div>
-          <div>
-            <div style={{ fontWeight: 600, marginBottom: 4 }}>
-              {t('pages.settings.subHappProxyDomains')}
-            </div>
-            <Input.TextArea
-              rows={2}
-              value={proxyDomains}
-              placeholder="geosite:google, youtube.com"
-              onChange={(e) => setProxyDomains(e.target.value)}
-            />
-          </div>
-          <div>
-            <div style={{ fontWeight: 600, marginBottom: 4 }}>
-              {t('pages.settings.subHappBlockDomains')}
-            </div>
-            <Input.TextArea
-              rows={2}
-              value={blockDomains}
-              placeholder="geosite:category-ads-all, analytics.google.com"
-              onChange={(e) => setBlockDomains(e.target.value)}
-            />
-          </div>
-          <div>
-            <div style={{ fontWeight: 600, marginBottom: 4 }}>
-              {t('pages.settings.subHappDirectIPs')}
-            </div>
-            <Input.TextArea
-              rows={2}
-              value={directIPs}
-              placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
-              onChange={(e) => setDirectIPs(e.target.value)}
-            />
-          </div>
-          <div>
-            <div style={{ fontWeight: 600, marginBottom: 4 }}>
-              {t('pages.settings.subHappProxyIPs')}
-            </div>
-            <Input.TextArea
-              rows={2}
-              value={proxyIPs}
-              placeholder="1.1.1.1/32, 8.8.8.8/32"
-              onChange={(e) => setProxyIPs(e.target.value)}
-            />
-          </div>
-          <div>
-            <div style={{ fontWeight: 600, marginBottom: 4 }}>
-              {t('pages.settings.subHappBlockIPs')}
-            </div>
-            <Input.TextArea
-              rows={2}
-              value={blockIPs}
-              placeholder="geoip:phishing, 0.0.0.0/8"
-              onChange={(e) => setBlockIPs(e.target.value)}
-            />
-          </div>
-        </Space>
-      </Modal>
+      {/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
+      {isModalOpen ? (
+        <HappRoutingEditorModal
+          input={allSetting.subRoutingRules}
+          onCancel={() => setIsModalOpen(false)}
+          onGenerate={handleBuildDeeplink}
+        />
+      ) : null}
     </>
   );
 }

+ 488 - 0
frontend/src/pages/settings/IncySettingsContent.tsx

@@ -0,0 +1,488 @@
+import { useTranslation } from 'react-i18next';
+import { Input, Select, Switch, Tabs } from 'antd';
+import {
+  AppstoreOutlined,
+  BranchesOutlined,
+  NotificationOutlined,
+  SafetyOutlined,
+  ThunderboltOutlined,
+} from '@ant-design/icons';
+import type { AllSetting } from '@/models/setting';
+import { SettingListItem } from '@/components/ui';
+import { catTabLabel } from './catTabLabel';
+
+interface IncySettingsContentProps {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+  isMobile: boolean;
+  remoteSourceBadge: (val: string) => React.ReactNode;
+}
+
+// Incy documents every switch as `1`/`0`; an empty value omits the header so
+// the subscriber's own app choice wins.
+const onOff = (t: (key: string) => string) => [
+  { value: '', label: t('pages.settings.subIncyNotSet') },
+  { value: '1', label: t('pages.settings.subIncyOn') },
+  { value: '0', label: t('pages.settings.subIncyOff') },
+];
+
+export default function IncySettingsContent({
+  allSetting,
+  updateSetting,
+  isMobile,
+  remoteSourceBadge,
+}: IncySettingsContentProps) {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.settings.subIncyAppAutoDetect')}
+        description={t('pages.settings.subIncyAppAutoDetectDesc')}
+      >
+        <Switch
+          checked={allSetting.subIncyAppAutoDetect}
+          onChange={(v) => updateSetting({ subIncyAppAutoDetect: v })}
+        />
+      </SettingListItem>
+
+      <Tabs
+        type="card"
+        size="small"
+        items={[
+          {
+            key: 'app',
+            label: catTabLabel(<AppstoreOutlined />, t('pages.settings.subIncyGroupApp'), isMobile),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyProfileDescription')}
+                  description={t('pages.settings.subIncyProfileDescriptionDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyProfileDescription}
+                    maxLength={200}
+                    onChange={(e) => updateSetting({ subIncyProfileDescription: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncySortOrder')}
+                  description={t('pages.settings.subIncySortOrderDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncySortOrder}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncySortOrder: v })}
+                    options={[
+                      { value: '', label: t('pages.settings.subIncyNotSet') },
+                      { value: 'none', label: t('pages.settings.subIncySortNone') },
+                      { value: 'ping', label: t('pages.settings.subIncySortPing') },
+                      { value: 'name', label: t('pages.settings.subIncySortName') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncySupportEmail')}
+                  description={t('pages.settings.subIncySupportEmailDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncySupportEmail}
+                    placeholder="[email protected]"
+                    onChange={(e) => updateSetting({ subIncySupportEmail: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyAnnounceUrl')}
+                  description={t('pages.settings.subIncyAnnounceUrlDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyAnnounceUrl}
+                    placeholder="https://t.me/your_channel"
+                    onChange={(e) => updateSetting({ subIncyAnnounceUrl: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyPremiumUrl')}
+                  description={t('pages.settings.subIncyPremiumUrlDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyPremiumUrl}
+                    placeholder="https://example.com/buy"
+                    onChange={(e) => updateSetting({ subIncyPremiumUrl: e.target.value })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'banners',
+            label: catTabLabel(
+              <NotificationOutlined />,
+              t('pages.settings.subIncyGroupBanners'),
+              isMobile,
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyBannerText')}
+                  description={t('pages.settings.subIncyBannerTextDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyBannerText}
+                    onChange={(e) => updateSetting({ subIncyBannerText: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyBannerButtonText')}
+                  description={t('pages.settings.subIncyBannerButtonTextDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyBannerButtonText}
+                    onChange={(e) => updateSetting({ subIncyBannerButtonText: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyBannerButtonUrl')}
+                  description={t('pages.settings.subIncyBannerButtonUrlDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyBannerButtonUrl}
+                    placeholder="https://example.com/sale"
+                    onChange={(e) => updateSetting({ subIncyBannerButtonUrl: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyBannerBgColor')}
+                  description={t('pages.settings.subIncyBannerBgColorDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyBannerBgColor}
+                    placeholder="#E53E3E"
+                    onChange={(e) => updateSetting({ subIncyBannerBgColor: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyBannerButtonColor')}
+                  description={t('pages.settings.subIncyBannerButtonColorDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyBannerButtonColor}
+                    placeholder="#38A169"
+                    onChange={(e) => updateSetting({ subIncyBannerButtonColor: e.target.value })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'privacy',
+            label: catTabLabel(
+              <SafetyOutlined />,
+              t('pages.settings.subIncyGroupPrivacy'),
+              isMobile,
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyHideUrl')}
+                  description={t('pages.settings.subIncyHideUrlDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyHideUrl}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyHideUrl: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyHideCheck')}
+                  description={t('pages.settings.subIncyHideCheckDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyHideCheck}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyHideCheck: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyNoLimit')}
+                  description={t('pages.settings.subIncyNoLimitDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyNoLimitEnabled}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyNoLimitEnabled: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyPerAppEnable')}
+                  description={t('pages.settings.subIncyPerAppEnableDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyPerAppEnable}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyPerAppEnable: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyPerAppMode')}
+                  description={t('pages.settings.subIncyPerAppModeDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyPerAppMode}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyPerAppMode: v })}
+                    options={[
+                      { value: '', label: t('pages.settings.subIncyNotSet') },
+                      { value: 'proxy', label: t('pages.settings.subIncyPerAppModeProxy') },
+                      { value: 'bypass', label: t('pages.settings.subIncyPerAppModeBypass') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyPerAppList')}
+                  description={t('pages.settings.subIncyPerAppListDesc')}
+                >
+                  <Input.TextArea
+                    value={allSetting.subIncyPerAppList}
+                    rows={4}
+                    placeholder={
+                      'com.google.chrome\norg.telegram.messenger\n\nor https://.../apps.txt'
+                    }
+                    onChange={(e) => updateSetting({ subIncyPerAppList: e.target.value })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'network',
+            label: catTabLabel(
+              <ThunderboltOutlined />,
+              t('pages.settings.subIncyGroupNetwork'),
+              isMobile,
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyFragmentationEnable')}
+                  description={t('pages.settings.subIncyFragmentationEnableDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyFragmentationEnable}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyFragmentationEnable: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyFragmentLength')}
+                  description={t('pages.settings.subIncyFragmentLengthDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyFragmentLength}
+                    placeholder="10-30"
+                    onChange={(e) => updateSetting({ subIncyFragmentLength: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyFragmentInterval')}
+                  description={t('pages.settings.subIncyFragmentIntervalDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyFragmentInterval}
+                    placeholder="20-40"
+                    onChange={(e) => updateSetting({ subIncyFragmentInterval: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyFragmentPackets')}
+                  description={t('pages.settings.subIncyFragmentPacketsDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyFragmentPackets}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyFragmentPackets: v })}
+                    options={[
+                      { value: '', label: t('pages.settings.subIncyNotSet') },
+                      { value: 'tlshello', label: 'tlshello' },
+                      { value: '1-3', label: '1-3' },
+                      { value: '1', label: '1' },
+                      { value: 'all', label: 'all' },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyNoisesEnable')}
+                  description={t('pages.settings.subIncyNoisesEnableDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyNoisesEnable}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyNoisesEnable: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyNoisesType')}
+                  description={t('pages.settings.subIncyNoisesTypeDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyNoisesType}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyNoisesType: v })}
+                    options={[
+                      { value: '', label: t('pages.settings.subIncyNotSet') },
+                      { value: 'rand', label: 'rand' },
+                      { value: 'str', label: 'str' },
+                      { value: 'hex', label: 'hex' },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyNoisesPacket')}
+                  description={t('pages.settings.subIncyNoisesPacketDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyNoisesPacket}
+                    placeholder="10-20"
+                    onChange={(e) => updateSetting({ subIncyNoisesPacket: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyNoisesDelay')}
+                  description={t('pages.settings.subIncyNoisesDelayDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyNoisesDelay}
+                    placeholder="10-50"
+                    onChange={(e) => updateSetting({ subIncyNoisesDelay: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyResolveEnable')}
+                  description={t('pages.settings.subIncyResolveEnableDesc')}
+                >
+                  <Select
+                    value={allSetting.subIncyResolveEnable}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subIncyResolveEnable: v })}
+                    options={onOff(t)}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyResolveDnsDomain')}
+                  description={t('pages.settings.subIncyResolveDnsDomainDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyResolveDnsDomain}
+                    placeholder="https://common.dot.dns.yandex.net/dns-query"
+                    onChange={(e) => updateSetting({ subIncyResolveDnsDomain: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subIncyResolveDnsIp')}
+                  description={t('pages.settings.subIncyResolveDnsIpDesc')}
+                >
+                  <Input
+                    value={allSetting.subIncyResolveDnsIp}
+                    placeholder="77.88.8.8"
+                    onChange={(e) => updateSetting({ subIncyResolveDnsIp: e.target.value })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'routing',
+            label: catTabLabel(
+              <BranchesOutlined />,
+              t('pages.settings.subIncyGroupRouting'),
+              isMobile,
+            ),
+            children: (
+              <>
+                <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')}
+                  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>
+              </>
+            ),
+          },
+        ]}
+      />
+    </>
+  );
+}

+ 27 - 24
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -19,6 +19,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 import HappSettingsContent from './HappSettingsContent';
+import IncySettingsContent from './IncySettingsContent';
 import { remoteSourceBadge } from './subscriptionShared';
 
 interface SubscriptionGeneralTabProps {
@@ -247,6 +248,26 @@ export default function SubscriptionGeneralTab({
                   onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
                 />
               </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.externalSubUserAgent')}
+                badge={
+                  <DefaultSettingTag
+                    settingKey="externalSubUserAgent"
+                    value={allSetting.externalSubUserAgent}
+                  />
+                }
+                description={t('pages.settings.externalSubUserAgentDesc')}
+              >
+                <Input
+                  value={allSetting.externalSubUserAgent}
+                  placeholder="v2rayNG/1.8.5"
+                  maxLength={512}
+                  allowClear
+                  onChange={(e) => updateSetting({ externalSubUserAgent: e.target.value })}
+                />
+              </SettingListItem>
             </>
           ),
         },
@@ -435,30 +456,12 @@ export default function SubscriptionGeneralTab({
           key: '7',
           label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
           children: (
-            <>
-              <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')}
-                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>
-            </>
+            <IncySettingsContent
+              allSetting={allSetting}
+              updateSetting={updateSetting}
+              isMobile={isMobile}
+              remoteSourceBadge={remoteSourceBadge}
+            />
           ),
         },
       ]}

+ 57 - 27
frontend/src/pages/settings/happPresets.ts

@@ -7,16 +7,11 @@ export function toBase64Utf8(str: string): string {
   );
 }
 
-// Splits multiline or comma-separated string into clean unique token arrays.
-export function parseList(input: string): string[] {
-  return input
-    .split(/[\n,]+/)
-    .map((s) => s.trim())
-    .filter(Boolean);
-}
-
 // Build standard Happ routing deeplink or special state for curated presets.
-export function buildHappPresetDeeplink(preset: string): string {
+export function buildHappPresetDeeplink(preset: string, includeAdblock = false): string {
+  // Ad blocking is opt-in for each generated profile, independent of the base routing rules.
+  const blockSites = includeAdblock ? ['geosite:category-ads-all'] : [];
+
   switch (preset) {
     case 'off':
       return 'happ://routing/off';
@@ -27,9 +22,20 @@ export function buildHappPresetDeeplink(preset: string): string {
           JSON.stringify({
             Name: 'Iran Bypass',
             GlobalProxy: 'true',
-            DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
-            DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
-            BlockSites: ['geosite:category-ads-all'],
+            RouteOrder: 'block-proxy-direct',
+            DirectSites: ['geosite:private', 'domain:ir', 'geosite:category-ir'],
+            DirectIp: [
+              'geoip:ir',
+              'geoip:private',
+              '127.0.0.0/8',
+              '10.0.0.0/8',
+              '172.16.0.0/12',
+              '192.168.0.0/16',
+              '169.254.0.0/16',
+              '224.0.0.0/4',
+              '255.255.255.255',
+            ],
+            BlockSites: blockSites,
             BlockIp: [],
             ProxySites: [],
             ProxyIp: [],
@@ -42,45 +48,69 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            Name: 'China Direct',
+            Name: 'Bypass-CN',
             GlobalProxy: 'true',
-            DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
-            DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
-            BlockSites: ['geosite:category-ads-all'],
-            BlockIp: [],
+            RouteOrder: 'block-proxy-direct',
+            RemoteDNSType: 'DoH',
+            RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
+            RemoteDNSIP: '1.1.1.1',
+            DomesticDNSType: 'DoH',
+            DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
+            DomesticDNSIP: '223.5.5.5',
+            DnsHosts: {
+              'cloudflare-dns.com': '1.1.1.1',
+              'dns.alidns.com': '223.5.5.5',
+            },
+            DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
+            DirectIp: [
+              'geoip:cn',
+              'geoip:private',
+              '127.0.0.0/8',
+              '10.0.0.0/8',
+              '172.16.0.0/12',
+              '192.168.0.0/16',
+              '169.254.0.0/16',
+              '224.0.0.0/4',
+              '255.255.255.255',
+            ],
             ProxySites: [],
             ProxyIp: [],
+            BlockSites: blockSites,
+            BlockIp: [],
             DomainStrategy: 'IPIfNonMatch',
+            FakeDNS: 'false',
+            UseChunkFiles: 'true',
           }),
         )
       );
-    case 'adblock':
+    case 'global':
       return (
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            Name: 'AdBlock',
+            Name: 'Global Proxy',
             GlobalProxy: 'true',
             DirectSites: [],
-            DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
-            BlockSites: ['geosite:category-ads-all'],
+            DirectIp: [],
+            BlockSites: blockSites,
             BlockIp: [],
             ProxySites: [],
             ProxyIp: [],
-            DomainStrategy: 'IPIfNonMatch',
+            DomainStrategy: 'AsIs',
           }),
         )
       );
-    case 'global':
+    // LAN bypass has its own identity so applying it does not overwrite the Global profile.
+    case 'lan-bypass':
       return (
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            Name: 'Global Proxy',
+            Name: 'Global Bypass Local Network',
             GlobalProxy: 'true',
-            DirectSites: [],
-            DirectIp: [],
-            BlockSites: [],
+            DirectSites: ['geosite:private'],
+            DirectIp: ['geoip:private'],
+            BlockSites: blockSites,
             BlockIp: [],
             ProxySites: [],
             ProxyIp: [],

+ 90 - 0
frontend/src/pages/settings/happRoutingEditor.ts

@@ -0,0 +1,90 @@
+import { HappRoutingProfileSchema, type HappRoutingProfile } from '@/schemas/happRouting';
+import { toBase64Utf8 } from './happPresets';
+import { isRemoteRoutingSource } from './subscriptionShared';
+
+export type HappRoutingMode = 'add' | 'onadd';
+export type HappRoutingListKey =
+  | 'DirectSites'
+  | 'DirectIp'
+  | 'ProxySites'
+  | 'ProxyIp'
+  | 'BlockSites'
+  | 'BlockIp';
+
+export type HappRoutingLoadResult =
+  | { success: true; profile: HappRoutingProfile; mode: HappRoutingMode; isNew: boolean }
+  | { success: false; error: 'off' | 'remote' | 'invalid' };
+
+export function parseHappRoutingJson(input: string): HappRoutingProfile | null {
+  try {
+    const value: unknown = JSON.parse(input);
+    const result = HappRoutingProfileSchema.safeParse(value);
+    // Keep the validated input object; schema output can discard some unknown extension keys.
+    return result.success ? (value as HappRoutingProfile) : null;
+  } catch {
+    return null;
+  }
+}
+
+export function loadHappRouting(input: string): HappRoutingLoadResult {
+  const source = input.trim();
+  if (!source) {
+    return {
+      success: true,
+      profile: {
+        Name: 'Custom Rules',
+        GlobalProxy: 'true',
+        DirectSites: [],
+        DirectIp: [],
+        ProxySites: [],
+        ProxyIp: [],
+        BlockSites: [],
+        BlockIp: [],
+        DomainStrategy: 'IPIfNonMatch',
+      },
+      mode: 'onadd',
+      isNew: true,
+    };
+  }
+  if (source === 'happ://routing/off') return { success: false, error: 'off' };
+  if (isRemoteRoutingSource(source)) return { success: false, error: 'remote' };
+
+  if (source.startsWith('{')) {
+    const profile = parseHappRoutingJson(source);
+    return profile
+      ? { success: true, profile, mode: 'onadd', isNew: false }
+      : { success: false, error: 'invalid' };
+  }
+
+  const match = /^happ:\/\/routing\/(onadd|add)\/([A-Za-z0-9+/_-]+={0,2})$/.exec(source);
+  if (!match) return { success: false, error: 'invalid' };
+
+  try {
+    const binary = atob(match[2].replace(/-/g, '+').replace(/_/g, '/'));
+    // Decode UTF-8 strictly so malformed bytes cannot silently change profile names or rules.
+    const json = new TextDecoder('utf-8', { fatal: true }).decode(
+      Uint8Array.from(binary, (character) => character.charCodeAt(0)),
+    );
+    const profile = parseHappRoutingJson(json);
+    return profile
+      ? { success: true, profile, mode: match[1] as HappRoutingMode, isNew: false }
+      : { success: false, error: 'invalid' };
+  } catch {
+    return { success: false, error: 'invalid' };
+  }
+}
+
+export function buildHappRoutingDeeplink(
+  profile: HappRoutingProfile,
+  mode: HappRoutingMode = 'onadd',
+): string {
+  return `happ://routing/${mode}/` + toBase64Utf8(JSON.stringify(profile));
+}
+
+export function parseHappRoutingList(input: string): string[] {
+  // Commas can belong to regexp rules, so the editor uses one rule per line.
+  return input
+    .split(/\r?\n/)
+    .map((entry) => entry.trim())
+    .filter(Boolean);
+}

+ 7 - 3
frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx

@@ -70,15 +70,19 @@ export default function AmneziawgFields() {
       <ObfNumber name="jc" label={t('pages.xray.amneziawg.jc')} min={0} />
       <ObfNumber name="jmin" label={t('pages.xray.amneziawg.jmin')} min={0} />
       <ObfNumber name="jmax" label={t('pages.xray.amneziawg.jmax')} min={0} />
-      <ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} />
-      <ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} />
-      <ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={64} />
+      <ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} max={65535} />
+      <ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} max={65535} />
+      <ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={65535} />
       <ObfNumber name="s4" label={t('pages.xray.amneziawg.s4')} min={0} max={32} />
       <ObfText name="h1" label={t('pages.xray.amneziawg.h1')} placeholder="100-800" />
       <ObfText name="h2" label={t('pages.xray.amneziawg.h2')} placeholder="900-1600" />
       <ObfText name="h3" label={t('pages.xray.amneziawg.h3')} placeholder="1700-2400" />
       <ObfText name="h4" label={t('pages.xray.amneziawg.h4')} placeholder="2500-3200" />
       <ObfText name="i1" label={t('pages.xray.amneziawg.i1')} placeholder="<r 64>" />
+      <ObfText name="i2" label={t('pages.xray.amneziawg.i2')} placeholder="<r 64>" />
+      <ObfText name="i3" label={t('pages.xray.amneziawg.i3')} placeholder="<r 64>" />
+      <ObfText name="i4" label={t('pages.xray.amneziawg.i4')} placeholder="<r 64>" />
+      <ObfText name="i5" label={t('pages.xray.amneziawg.i5')} placeholder="<r 64>" />
       <ObfText
         name="contentPaddingAddition"
         label={t('pages.xray.amneziawg.contentPaddingAddition')}

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

@@ -41,6 +41,7 @@ export const ClientRecordSchema = z
     enable: z.boolean().optional(),
     reset: z.number().optional(),
     resetDay: z.number().optional(),
+    resetWeekday: z.number().optional(),
     resetMax: z.number().optional(),
     trafficReset: z.string().optional(),
     trafficResetDay: z.number().optional(),
@@ -328,6 +329,7 @@ export const ClientFormSchema = z.object({
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
   resetDay: z.number().int().min(0).max(31),
+  resetWeekday: z.number().int().min(0).max(7),
   resetMax: z.number().int().min(0),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
   trafficResetDay: z.number().int().min(1).max(31),
@@ -392,6 +394,7 @@ export const ClientBulkAddFormSchema = z.object({
   expiryTime: z.number(),
   reset: z.number().int().min(0),
   resetDay: z.number().int().min(0).max(31),
+  resetWeekday: z.number().int().min(0).max(7),
   resetMax: z.number().int().min(0),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']).optional(),
   trafficResetDay: z.number().int().min(1).max(31).optional(),

+ 16 - 0
frontend/src/schemas/happRouting.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+// Extensions belong to the Happ client and must survive a trip through this editor.
+export const HappRoutingProfileSchema = z
+  .object({
+    // The backend treats null lists as absent; preserve them until explicitly edited.
+    DirectSites: z.array(z.string()).nullish(),
+    DirectIp: z.array(z.string()).nullish(),
+    ProxySites: z.array(z.string()).nullish(),
+    ProxyIp: z.array(z.string()).nullish(),
+    BlockSites: z.array(z.string()).nullish(),
+    BlockIp: z.array(z.string()).nullish(),
+  })
+  .catchall(z.unknown());
+
+export type HappRoutingProfile = z.infer<typeof HappRoutingProfileSchema>;

+ 3 - 3
frontend/src/schemas/protocols/inbound/amneziawg.ts

@@ -71,9 +71,9 @@ export const AmneziawgServerSchema = z.object({
   jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
   jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
   jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
-  s1: clearedToDefault(z.number().int().min(0).max(65535).default(30)),
-  s2: clearedToDefault(z.number().int().min(0).max(65535).default(45)),
-  s3: clearedToDefault(z.number().int().min(0).max(64).default(10)),
+  s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)),
+  s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)),
+  s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)),
   s4: clearedToDefault(z.number().int().min(0).max(32).default(5)),
   h1: z.string().default(''),
   h2: z.string().default(''),

+ 4 - 3
frontend/src/schemas/protocols/outbound/amneziawg.ts

@@ -22,9 +22,10 @@ export const AmneziaWGOutboundSettingsSchema = z.object({
   jc: z.number().int().min(0).default(0),
   jmin: z.number().int().min(0).default(40),
   jmax: z.number().int().min(0).default(100),
-  s1: z.number().int().min(0).default(15),
-  s2: z.number().int().min(0).default(80),
-  s3: z.number().int().min(0).max(64).default(12),
+  // The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here.
+  s1: z.number().int().min(0).max(65535).default(15),
+  s2: z.number().int().min(0).max(65535).default(80),
+  s3: z.number().int().min(0).max(65535).default(12),
   s4: z.number().int().min(0).max(32).default(12),
   h1: z.string().default(''),
   h2: z.string().default(''),

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

@@ -72,6 +72,7 @@ export const AllSettingSchema = z
     restartXrayOnClientDisable: z.boolean().optional(),
     subCertFile: z.string().optional(),
     subKeyFile: z.string().optional(),
+    externalSubUserAgent: z.string().max(512).optional(),
     subUpdates: z.number().int().min(0).max(525600).optional(),
     subEncrypt: z.boolean().optional(),
     subURI: z.string().optional(),
@@ -109,6 +110,35 @@ export const AllSettingSchema = z
     subHappAutoConnectType: z.string().optional(),
     subHappPerAppMode: z.string().optional(),
     subHappPerAppList: z.string().optional(),
+    subHappLocalProxyAuth: z.string().optional(),
+    subIncyAppAutoDetect: z.boolean().optional(),
+    subIncyProfileDescription: z.string().optional(),
+    subIncySortOrder: z.string().optional(),
+    subIncySupportEmail: z.string().optional(),
+    subIncyAnnounceUrl: z.string().optional(),
+    subIncyPremiumUrl: z.string().optional(),
+    subIncyBannerText: z.string().optional(),
+    subIncyBannerButtonText: z.string().optional(),
+    subIncyBannerButtonUrl: z.string().optional(),
+    subIncyBannerBgColor: z.string().optional(),
+    subIncyBannerButtonColor: z.string().optional(),
+    subIncyHideUrl: z.string().optional(),
+    subIncyHideCheck: z.string().optional(),
+    subIncyNoLimitEnabled: z.string().optional(),
+    subIncyPerAppEnable: z.string().optional(),
+    subIncyPerAppMode: z.string().optional(),
+    subIncyPerAppList: z.string().optional(),
+    subIncyFragmentationEnable: z.string().optional(),
+    subIncyFragmentLength: z.string().optional(),
+    subIncyFragmentInterval: z.string().optional(),
+    subIncyFragmentPackets: z.string().optional(),
+    subIncyNoisesEnable: z.string().optional(),
+    subIncyNoisesType: z.string().optional(),
+    subIncyNoisesPacket: z.string().optional(),
+    subIncyNoisesDelay: z.string().optional(),
+    subIncyResolveEnable: z.string().optional(),
+    subIncyResolveDnsDomain: z.string().optional(),
+    subIncyResolveDnsIp: z.string().optional(),
     timeLocation: z.string().optional(),
     ldapEnable: z.boolean().optional(),
     ldapHost: z.string().optional(),

+ 22 - 9
frontend/src/test/amneziawg-schema-cleared.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest';
 
 import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
+import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound/amneziawg';
 
 // AntD InputNumber emits null when cleared; a cleared numeric field must
 // refill its schema default instead of failing validation and blocking the save.
@@ -33,27 +34,27 @@ describe('AmneziawgServerSchema cleared numeric fields', () => {
   });
 });
 
-// The form must reject what amneziawg-go's UAPI parsers reject (device/uapi.go:
-// jc/jmin/jmax uint32, s1-s4 uint16), or the save silently outlives the apply.
+// The form must reject what cannot apply or be received: jc/jmin/jmax past uint32 (device/uapi.go),
+// S1-S3 past amneziawg-go's 1700-byte iOS receive buffer, S4 past 32 (MTU headroom).
 describe('AmneziawgServerSchema obfuscation bounds', () => {
   const overWidth: Array<[string, number]> = [
-    ['s1', 65536],
-    ['s2', 70000],
-    ['s3', 65],
+    ['s1', 1553],
+    ['s2', 1609],
+    ['s3', 1637],
     ['s4', 33],
     ['jc', 4294967296],
     ['jmin', 4294967296],
     ['jmax', 5000000000],
   ];
 
-  it.each(overWidth)('rejects %s above the width amneziawg-go parses', (field, value) => {
+  it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => {
     expect(AmneziawgServerSchema.safeParse({ [field]: value }).success).toBe(false);
   });
 
   const atLimit: Array<[string, number]> = [
-    ['s1', 65535],
-    ['s2', 65535],
-    ['s3', 64],
+    ['s1', 1552],
+    ['s2', 1608],
+    ['s3', 1636],
     ['s4', 32],
     ['jc', 4294967295],
   ];
@@ -69,3 +70,15 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
     }
   });
 });
+
+// An outbound's S values come from the remote server and are received on Linux,
+// so only amneziawg-go's uint16 UAPI width bounds them, not the iOS buffer.
+describe('AmneziaWGOutboundSettingsSchema padding bounds', () => {
+  it.each(['s1', 's2', 's3'])('accepts %s past the inbound iOS cap', (field) => {
+    expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 2000 }).success).toBe(true);
+  });
+
+  it.each(['s1', 's2', 's3'])('rejects %s past uint16', (field) => {
+    expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 65536 }).success).toBe(false);
+  });
+});

+ 113 - 0
frontend/src/test/client-bulk-calendar-renewal.test.tsx

@@ -0,0 +1,113 @@
+import { expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+
+import ClientBulkAddModal from '@/pages/clients/ClientBulkAddModal';
+import { HttpUtil, Msg } from '@/utils';
+import { chooseSelectOption, renderWithProviders } from './test-utils';
+
+const { bulkCreate } = vi.hoisted(() => ({ bulkCreate: vi.fn() }));
+vi.mock('@/hooks/useClients', () => ({ useClients: () => ({ bulkCreate }) }));
+
+it('keeps bulk renewal disabled by default and requires explicit cutoff selection without changing first-use duration', async () => {
+  bulkCreate.mockResolvedValue(new Msg(true, '', { created: 1, skipped: [] }));
+  const post = vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, body) => {
+    if (url !== '/panel/api/clients/renewalPreview')
+      return new Msg(true, '', { datepicker: 'gregorian' });
+    const request = body as { expiryTime: number };
+    return new Msg(true, '', {
+      timeZone: 'Asia/Taipei',
+      renewAt: '',
+      validThrough: '',
+      nextExpiry: '',
+      suggestedExpiryTime: 1893427200000,
+      suggestedExpiry: '2030-01-01T00:00:00+08:00',
+      renewals: 0,
+      canRenew: false,
+      delayedStart: request.expiryTime < 0,
+    });
+  });
+  async function submit() {
+    const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
+    if (!button) throw new Error('Create button missing');
+    await waitFor(() => expect(button.classList.contains('ant-btn-loading')).toBe(false));
+    fireEvent.click(button);
+  }
+  try {
+    renderWithProviders(
+      <ClientBulkAddModal
+        open
+        inbounds={[{ id: 1, protocol: 'vless', tag: 'calendar' }]}
+        onOpenChange={() => {}}
+      />,
+    );
+    fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
+    const mode = screen.getByLabelText('Auto renewal');
+    expect(mode.closest('.ant-select')?.textContent).toContain('Disabled');
+    expect(post.mock.calls.some(([url]) => url === '/panel/api/clients/renewalPreview')).toBe(
+      false,
+    );
+    await submit();
+    await waitFor(() =>
+      expect(bulkCreate).toHaveBeenCalledWith([
+        expect.objectContaining({
+          client: expect.objectContaining({
+            reset: 0,
+            resetDay: 0,
+            resetWeekday: 0,
+            expiryTime: 0,
+          }),
+        }),
+      ]),
+    );
+    chooseSelectOption(mode.id, 'Calendar weekly');
+    await waitFor(() => expect(document.body.textContent).toContain('An expiry must be set'));
+    await submit();
+    await waitFor(() =>
+      expect(bulkCreate).toHaveBeenLastCalledWith([
+        expect.objectContaining({
+          client: expect.objectContaining({
+            reset: 0,
+            resetDay: 0,
+            resetWeekday: 1,
+            expiryTime: 0,
+          }),
+        }),
+      ]),
+    );
+    fireEvent.click(screen.getByRole('button', { name: /Set first cycle cutoff/ }));
+    await submit();
+    await waitFor(() =>
+      expect(bulkCreate).toHaveBeenLastCalledWith([
+        expect.objectContaining({
+          client: expect.objectContaining({ resetWeekday: 1, expiryTime: 1893427200000 }),
+        }),
+      ]),
+    );
+    const label = Array.from(document.querySelectorAll('.ant-form-item-label label')).find(
+      (el) => el.textContent === 'Start After First Use',
+    );
+    const toggle = label?.closest('.ant-form-item')?.querySelector('[role="switch"]');
+    if (!toggle) throw new Error('First-use switch missing');
+    fireEvent.click(toggle);
+    const daysLabel = Array.from(document.querySelectorAll('.ant-form-item-label label')).find(
+      (el) => el.textContent === 'Duration (days)',
+    );
+    const daysInput = daysLabel?.closest('.ant-form-item')?.querySelector('input');
+    if (!daysInput) throw new Error('First-use days input missing');
+    fireEvent.change(daysInput, { target: { value: '7' } });
+    await waitFor(() =>
+      expect(document.body.textContent).toContain('Dates are available after first-use activation'),
+    );
+    expect(screen.queryByRole('button', { name: /Set first cycle cutoff/ })).toBeNull();
+    await submit();
+    await waitFor(() =>
+      expect(bulkCreate).toHaveBeenLastCalledWith([
+        expect.objectContaining({
+          client: expect.objectContaining({ resetWeekday: 1, expiryTime: -604800000 }),
+        }),
+      ]),
+    );
+  } finally {
+    post.mockRestore();
+  }
+});

+ 109 - 0
frontend/src/test/client-calendar-renewal.test.tsx

@@ -0,0 +1,109 @@
+import { expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+
+import ClientFormModal from '@/pages/clients/ClientFormModal';
+import { HttpUtil, Msg } from '@/utils';
+import { chooseSelectOption, renderWithProviders } from './test-utils';
+
+it('preserves monthly clients, previews backend dates, and saves exclusive weekly or disabled modes', async () => {
+  const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
+    new Msg(true, '', {
+      timeZone: 'Asia/Taipei',
+      renewAt: '2030-01-01T00:00:00+08:00',
+      validThrough: '2029-12-31T23:59:59+08:00',
+      nextExpiry: '2030-02-01T00:00:00+08:00',
+      suggestedExpiryTime: 1893427200000,
+      suggestedExpiry: '2030-01-01T00:00:00+08:00',
+      renewals: 1,
+      canRenew: true,
+      delayedStart: false,
+    }),
+  );
+  const save = vi.fn().mockResolvedValue(new Msg(true, '', null));
+  async function submit() {
+    const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
+    if (!button) throw new Error('Save button missing');
+    await waitFor(() => expect(button.classList.contains('ant-btn-loading')).toBe(false));
+    fireEvent.click(button);
+  }
+  try {
+    renderWithProviders(
+      <ClientFormModal
+        open
+        mode="edit"
+        client={{
+          email: '[email protected]',
+          uuid: '11111111-1111-1111-1111-111111111111',
+          subId: 'calendar-sub',
+          enable: true,
+          expiryTime: 1893427200000,
+          resetDay: 1,
+          reset: 7,
+          resetMax: 3,
+          traffic: { resetCount: 2 },
+        }}
+        attachedIds={[1]}
+        inbounds={[{ id: 1, protocol: 'vless', tag: 'calendar' }]}
+        save={save}
+        onOpenChange={() => {}}
+      />,
+    );
+    const mode = screen.getByLabelText('Auto renewal');
+    expect(mode.closest('.ant-select')?.textContent).toContain('Calendar monthly');
+    await waitFor(() => expect(document.body.textContent).toContain('2029-12-31T23:59:59+08:00'));
+    expect(post).toHaveBeenCalledWith(
+      '/panel/api/clients/renewalPreview',
+      expect.objectContaining({ resetMax: 3, resetCount: 2 }),
+      expect.anything(),
+    );
+    await submit();
+    await waitFor(() =>
+      expect(save).toHaveBeenCalledWith(
+        expect.objectContaining({
+          reset: 7,
+          resetDay: 1,
+          resetWeekday: 0,
+          expiryTime: 1893427200000,
+        }),
+        expect.anything(),
+      ),
+    );
+    chooseSelectOption(mode.id, 'Calendar weekly');
+    const weekday = screen.getByLabelText('Renew on weekday');
+    chooseSelectOption(weekday.id, 'Sunday');
+    await waitFor(() =>
+      expect(post).toHaveBeenCalledWith(
+        '/panel/api/clients/renewalPreview',
+        expect.objectContaining({ reset: 0, resetDay: 0, resetWeekday: 7 }),
+        expect.anything(),
+      ),
+    );
+    await submit();
+    await waitFor(() =>
+      expect(save).toHaveBeenCalledWith(
+        expect.objectContaining({
+          reset: 0,
+          resetDay: 0,
+          resetWeekday: 7,
+          expiryTime: 1893427200000,
+        }),
+        expect.anything(),
+      ),
+    );
+    chooseSelectOption(mode.id, 'Disabled');
+    await submit();
+    await waitFor(() =>
+      expect(save).toHaveBeenCalledWith(
+        expect.objectContaining({
+          reset: 0,
+          resetDay: 0,
+          resetWeekday: 0,
+          expiryTime: 1893427200000,
+        }),
+        expect.anything(),
+      ),
+    );
+  } finally {
+    post.mockRestore();
+  }
+});

+ 20 - 2
frontend/src/test/client-toggle-traffic-reset.test.tsx

@@ -18,7 +18,16 @@ describe('client enable toggle', () => {
       const email = '[email protected]';
       vi.spyOn(HttpUtil, 'get').mockResolvedValue(
         new Msg(true, '', {
-          client: { email, enable: !enable, trafficReset: 'monthly', trafficResetDay: 15 },
+          client: {
+            email,
+            enable: !enable,
+            trafficReset: 'monthly',
+            trafficResetDay: 15,
+            reset: 0,
+            resetDay: 0,
+            resetWeekday: 7,
+            resetMax: 3,
+          },
           inboundIds: [],
         }),
       );
@@ -44,7 +53,16 @@ describe('client enable toggle', () => {
       expect(HttpUtil.get).toHaveBeenCalledWith('/panel/api/clients/get/scheduled%40example.com');
       expect(post).toHaveBeenCalledWith(
         '/panel/api/clients/update/scheduled%40example.com',
-        expect.objectContaining({ email, enable, trafficReset: 'monthly', trafficResetDay: 15 }),
+        expect.objectContaining({
+          email,
+          enable,
+          trafficReset: 'monthly',
+          trafficResetDay: 15,
+          reset: 0,
+          resetDay: 0,
+          resetWeekday: 7,
+          resetMax: 3,
+        }),
         { headers: { 'Content-Type': 'application/json' } },
       );
     },

+ 77 - 19
frontend/src/test/happ-presets.test.ts

@@ -1,14 +1,8 @@
 import { describe, expect, it } from 'vitest';
 
-import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
+import { buildHappPresetDeeplink, toBase64Utf8 } from '@/pages/settings/happPresets';
 
 describe('Happ presets and helpers', () => {
-  it('correctly parses comma and newline separated lists', () => {
-    const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
-    const parsed = parseList(raw);
-    expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
-  });
-
   it('generates valid happ://routing/off for off preset', () => {
     const link = buildHappPresetDeeplink('off');
     expect(link).toBe('happ://routing/off');
@@ -26,7 +20,7 @@ describe('Happ presets and helpers', () => {
     expect(parsed.GlobalProxy).toBe('true');
     expect(parsed.DirectSites).toContain('domain:ir');
     expect(parsed.DirectIp).toContain('geoip:ir');
-    expect(parsed.BlockSites).toContain('geosite:category-ads-all');
+    expect(parsed.BlockSites).toEqual([]);
   });
 
   it('generates valid base64 payload for china-direct preset', () => {
@@ -37,28 +31,92 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    expect(parsed.Name).toBe('China Direct');
-    expect(parsed.DirectSites).toContain('geosite:cn');
-    expect(parsed.DirectIp).toContain('geoip:cn');
+    expect(parsed).toEqual({
+      Name: 'Bypass-CN',
+      GlobalProxy: 'true',
+      RouteOrder: 'block-proxy-direct',
+      RemoteDNSType: 'DoH',
+      RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
+      RemoteDNSIP: '1.1.1.1',
+      DomesticDNSType: 'DoH',
+      DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
+      DomesticDNSIP: '223.5.5.5',
+      DnsHosts: {
+        'cloudflare-dns.com': '1.1.1.1',
+        'dns.alidns.com': '223.5.5.5',
+      },
+      DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
+      DirectIp: [
+        'geoip:cn',
+        'geoip:private',
+        '127.0.0.0/8',
+        '10.0.0.0/8',
+        '172.16.0.0/12',
+        '192.168.0.0/16',
+        '169.254.0.0/16',
+        '224.0.0.0/4',
+        '255.255.255.255',
+      ],
+      ProxySites: [],
+      ProxyIp: [],
+      BlockSites: [],
+      BlockIp: [],
+      DomainStrategy: 'IPIfNonMatch',
+      FakeDNS: 'false',
+      UseChunkFiles: 'true',
+    });
   });
 
-  it('generates valid base64 payload for adblock preset', () => {
-    const link = buildHappPresetDeeplink('adblock');
-    const b64 = link.replace('happ://routing/onadd/', '');
-    const jsonStr = atob(b64);
-    const parsed = JSON.parse(jsonStr);
+  it.each(['iran-bypass', 'china-direct', 'global', 'lan-bypass'])(
+    'adds ad blocking only when opted in for %s without changing its routing',
+    (preset) => {
+      const decode = (link: string) => JSON.parse(atob(link.replace('happ://routing/onadd/', '')));
+      const base = decode(buildHappPresetDeeplink(preset));
+      const withAds = decode(buildHappPresetDeeplink(preset, true));
+      const withoutAds = decode(buildHappPresetDeeplink(preset, false));
+
+      expect(base.BlockSites).toEqual([]);
+      expect(withAds.BlockSites).toEqual(['geosite:category-ads-all']);
+      expect({ ...withAds, BlockSites: [] }).toEqual(base);
+      expect(withoutAds).toEqual(base);
+    },
+  );
 
-    expect(parsed.Name).toBe('AdBlock');
-    expect(parsed.BlockSites).toContain('geosite:category-ads-all');
+  it('keeps routing disabled even when ad blocking is selected', () => {
+    expect(buildHappPresetDeeplink('off', true)).toBe('happ://routing/off');
   });
 
-  it('generates valid base64 payload for global preset', () => {
+  it.each(['adblock', 'unknown'])(
+    'does not generate a profile for unsupported preset %s',
+    (preset) => {
+      expect(buildHappPresetDeeplink(preset)).toBe('');
+    },
+  );
+
+  it('keeps the global preset without direct exceptions', () => {
     const link = buildHappPresetDeeplink('global');
     const b64 = link.replace('happ://routing/onadd/', '');
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
     expect(parsed.Name).toBe('Global Proxy');
+    expect(parsed.GlobalProxy).toBe('true');
+    expect(parsed.DirectSites).toEqual([]);
+    expect(parsed.DirectIp).toEqual([]);
+    expect(parsed.DomainStrategy).toBe('AsIs');
+  });
+
+  it('generates a separate LAN bypass preset with proxy as the default', () => {
+    const link = buildHappPresetDeeplink('lan-bypass');
+    const prefix = 'happ://routing/onadd/';
+    expect(link.startsWith(prefix)).toBe(true);
+    const parsed = JSON.parse(atob(link.slice(prefix.length)));
+
+    expect(parsed.Name).toBe('Global Bypass Local Network');
+    expect(parsed.GlobalProxy).toBe('true');
+    expect(parsed.DirectSites).toEqual(['geosite:private']);
+    expect(parsed.DirectIp).toEqual(['geoip:private']);
+    expect(parsed.BlockSites).toEqual([]);
     expect(parsed.DomainStrategy).toBe('AsIs');
   });
 

+ 156 - 0
frontend/src/test/happ-routing-editor-helpers.test.ts

@@ -0,0 +1,156 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  buildHappRoutingDeeplink,
+  loadHappRouting,
+  parseHappRoutingJson,
+  parseHappRoutingList,
+} from '@/pages/settings/happRoutingEditor';
+
+const profile = {
+  Name: '大陆 · فارسی 🛣',
+  GlobalProxy: 'false',
+  RouteOrder: 'direct-proxy-block',
+  DomainStrategy: 'AsIs',
+  RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
+  DnsHosts: { 'dns.alidns.com': '223.5.5.5' },
+  DirectSites: ['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$'],
+  DirectIp: ['geoip:cn'],
+  BlockSites: ['geosite:category-ads-all'],
+  FutureSetting: { enabled: true, values: ['保留', 7, null] },
+};
+
+describe('Happ routing editor profile loading', () => {
+  it.each(['onadd', 'add'] as const)('loads %s with UTF-8 and preserves every field', (mode) => {
+    const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
+    expect(loadHappRouting(`  happ://routing/${mode}/${encoded}\n`)).toEqual({
+      success: true,
+      profile,
+      mode,
+      isNew: false,
+    });
+  });
+
+  it.each(['base64', 'base64url'] as const)(
+    'accepts padded and unpadded %s payloads',
+    (encoding) => {
+      const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString(encoding);
+      const unpadded = encoded.replace(/=+$/, '');
+      const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
+      for (const payload of [padded, unpadded]) {
+        const loaded = loadHappRouting(`happ://routing/onadd/${payload}`);
+        expect(loaded).toMatchObject({ success: true, profile });
+      }
+    },
+  );
+
+  it('loads raw JSON without filling absent fields or discarding extensions', () => {
+    const input = '{"Name":"existing","GlobalProxy":"false","Future":{"nested":[1,null]}}';
+    expect(loadHappRouting(input)).toEqual({
+      success: true,
+      profile: { Name: 'existing', GlobalProxy: 'false', Future: { nested: [1, null] } },
+      mode: 'onadd',
+      isNew: false,
+    });
+  });
+
+  it.each(['json', 'add', 'onadd'] as const)('preserves null lists in a %s profile', (format) => {
+    const nullableProfile = {
+      ...profile,
+      DirectSites: null,
+      DirectIp: null,
+      ProxySites: null,
+      ProxyIp: null,
+      BlockSites: null,
+      BlockIp: null,
+    };
+    const json = JSON.stringify(nullableProfile);
+    const source =
+      format === 'json'
+        ? json
+        : `happ://routing/${format}/${Buffer.from(json, 'utf8').toString('base64')}`;
+
+    expect(loadHappRouting(source)).toEqual({
+      success: true,
+      profile: nullableProfile,
+      mode: format === 'json' ? 'onadd' : format,
+      isNew: false,
+    });
+  });
+
+  it('retains the existing new-profile flow only for an empty source', () => {
+    expect(loadHappRouting(' \n ')).toEqual({
+      success: true,
+      profile: {
+        Name: 'Custom Rules',
+        GlobalProxy: 'true',
+        DirectSites: [],
+        DirectIp: [],
+        ProxySites: [],
+        ProxyIp: [],
+        BlockSites: [],
+        BlockIp: [],
+        DomainStrategy: 'IPIfNonMatch',
+      },
+      mode: 'onadd',
+      isNew: true,
+    });
+  });
+
+  it.each([
+    ['happ://routing/off', 'off'],
+    ['https://example.com/rules', 'remote'],
+    [' \nHTTPS://example.com/rules\t ', 'remote'],
+    ['http://example.com/rules', 'invalid'],
+    ['https://', 'invalid'],
+    ['https://example.com/rules with spaces', 'invalid'],
+    ['https://example.com/rules\nsecond-line', 'invalid'],
+    ['happ://routing/onadd/', 'invalid'],
+    ['happ://routing/onadd/%invalid', 'invalid'],
+    ['happ://routing/onadd/e3\n0=', 'invalid'],
+    ['happ://routing/add//w==', 'invalid'],
+    ['happ://routing/onadd/W10=', 'invalid'],
+    ['happ://unknown/e30=', 'invalid'],
+    ['{"DirectSites":"geosite:cn"}', 'invalid'],
+    ['{"DirectIp":[3]}', 'invalid'],
+    ['{"Name":', 'invalid'],
+  ])('does not replace an unloadable source %s with a new profile', (source, error) => {
+    expect(loadHappRouting(source)).toEqual({ success: false, error });
+  });
+});
+
+describe('Happ routing editor JSON and output', () => {
+  it('preserves all fields through a valid JSON edit', () => {
+    expect(parseHappRoutingJson(JSON.stringify(profile))).toEqual(profile);
+  });
+
+  it('preserves extension keys as JSON data without changing object prototypes', () => {
+    const source = '{"Name":"extensions","__proto__":{"clientOption":true},"DirectSites":[]}';
+    const parsed = parseHappRoutingJson(source);
+    expect(parsed).not.toBeNull();
+    if (!parsed) throw new Error('Expected a valid profile');
+
+    const rebuilt = JSON.parse(atob(buildHappRoutingDeeplink(parsed).split('/onadd/')[1]));
+    expect(rebuilt).toEqual(JSON.parse(source));
+    expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
+  });
+
+  it.each(['', '{', '[]', 'null', 'true', '42', '{"BlockIp":[null]}', '{"ProxySites":[{}]}'])(
+    'rejects an invalid profile %s',
+    (source) => {
+      expect(parseHappRoutingJson(source)).toBeNull();
+    },
+  );
+
+  it('generates a standard UTF-8 link while retaining add semantics and unknown fields', () => {
+    const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
+    expect(buildHappRoutingDeeplink(profile, 'add')).toBe(`happ://routing/add/${encoded}`);
+    expect(buildHappRoutingDeeplink(profile)).toBe(`happ://routing/onadd/${encoded}`);
+  });
+
+  it('keeps regexp commas intact when editing one rule per line', () => {
+    expect(
+      parseHappRoutingList('  geosite:cn\r\nregexp:^host[0-9]{1,3}\\.example$\n\n domain:local '),
+    ).toEqual(['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$', 'domain:local']);
+  });
+});

+ 322 - 0
frontend/src/test/happ-routing-editor.test.tsx

@@ -0,0 +1,322 @@
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, screen, within } from '@testing-library/react';
+import { EditorView } from 'codemirror';
+
+import { AllSetting } from '@/models/setting';
+import HappSettingsContent from '@/pages/settings/HappSettingsContent';
+import { toBase64Utf8 } from '@/pages/settings/happPresets';
+
+import { renderWithProviders } from './test-utils';
+
+const profile = {
+  Name: '当前草稿',
+  GlobalProxy: 'true',
+  RouteOrder: 'block-proxy-direct',
+  RemoteDNSDomain: 'https://dns.example/dns-query',
+  DomainStrategy: 'IPIfNonMatch',
+  DnsHosts: { 'dns.example': '1.1.1.1' },
+  DirectSites: ['geosite:cn', 'regexp:^example{1,2}\\.com$'],
+  ProxySites: ['geosite:google'],
+  BlockSites: ['geosite:category-ads-all'],
+  DirectIp: ['geoip:cn'],
+  ProxyIp: ['1.1.1.1/32'],
+  BlockIp: ['0.0.0.0/8'],
+  FutureSetting: { nested: [{ enabled: false, count: 0, text: '保留' }] },
+};
+
+const nullableProfile = {
+  ...profile,
+  DirectSites: null,
+  DirectIp: null,
+  ProxySites: null,
+  ProxyIp: null,
+  BlockSites: null,
+  BlockIp: null,
+};
+
+function routingLink(value: Record<string, unknown>, mode = 'onadd') {
+  return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
+}
+
+function renderSettings(input = routingLink(profile)) {
+  const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
+
+  function SettingsHarness() {
+    const [allSetting, setAllSetting] = useState(() =>
+      Object.assign(new AllSetting(), { subRoutingRules: input }),
+    );
+
+    return (
+      <HappSettingsContent
+        allSetting={allSetting}
+        updateSetting={(patch) => {
+          updateSetting(patch);
+          setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
+        }}
+        isMobile={false}
+        remoteSourceBadge={() => null}
+      />
+    );
+  }
+
+  renderWithProviders(<SettingsHarness />);
+  return updateSetting;
+}
+
+function openEditor() {
+  fireEvent.click(screen.getByRole('button', { name: 'Visual Rule Generator' }));
+  return within(screen.getByRole('dialog'));
+}
+
+function routingRules() {
+  return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
+}
+
+function generatedProfile(): Record<string, unknown> {
+  const payload = routingRules().replace(/^happ:\/\/routing\/(?:onadd|add)\//, '');
+  return JSON.parse(
+    new TextDecoder().decode(Uint8Array.from(atob(payload), (char) => char.charCodeAt(0))),
+  ) as Record<string, unknown>;
+}
+
+function basicField(name: string) {
+  return screen.getByRole('textbox', { name }) as HTMLTextAreaElement;
+}
+
+function jsonEditor() {
+  const content = screen.getByRole('textbox', { name: 'JSON editor' });
+  const editor = EditorView.findFromDOM(content);
+  if (!editor) throw new Error('CodeMirror editor not mounted');
+  return editor;
+}
+
+function changeJson(value: string) {
+  const editor = jsonEditor();
+  act(() => {
+    editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
+  });
+}
+
+describe('Happ routing editor', () => {
+  it('loads the current unsaved routing draft when opened', () => {
+    const updateSetting = renderSettings();
+    const latest = { ...profile, DirectSites: ['domain:latest-draft.example'] };
+    fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
+      target: { value: routingLink(latest) },
+    });
+    updateSetting.mockClear();
+
+    const dialog = openEditor();
+
+    expect((dialog.getAllByRole('textbox')[0] as HTMLTextAreaElement).value).toBe(
+      'domain:latest-draft.example',
+    );
+    expect(updateSetting).not.toHaveBeenCalled();
+  });
+
+  it.each(['add', 'onadd'])(
+    'preserves other fields and %s semantics when editing basic rules',
+    (mode) => {
+      const updateSetting = renderSettings(routingLink(profile, mode));
+      openEditor();
+      const edited = 'regexp:^other{1,2}\\.com$\n  domain:another.example\n';
+      fireEvent.change(basicField('Direct Domains (Bypass)'), { target: { value: edited } });
+
+      expect(basicField('Direct Domains (Bypass)').value).toBe(edited);
+      expect(updateSetting).not.toHaveBeenCalled();
+      fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+
+      expect(routingRules().startsWith(`happ://routing/${mode}/`)).toBe(true);
+      expect(generatedProfile()).toEqual({
+        ...profile,
+        DirectSites: ['regexp:^other{1,2}\\.com$', 'domain:another.example'],
+      });
+      expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
+      expect(screen.queryByRole('dialog')).toBeNull();
+    },
+  );
+
+  it('syncs both tabs while retaining full-profile edits', () => {
+    const updateSetting = renderSettings();
+    openEditor();
+    fireEvent.change(basicField('Blocked Domains (Ad/Malware)'), {
+      target: { value: 'geosite:category-ads-all\ndomain:ads.example' },
+    });
+    fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
+    const advanced = JSON.parse(jsonEditor().state.doc.toString()) as Record<string, unknown>;
+    expect(advanced).toEqual({
+      ...profile,
+      BlockSites: ['geosite:category-ads-all', 'domain:ads.example'],
+    });
+
+    const next = { ...advanced, ProxyIp: ['9.9.9.9/32'], FutureSetting: { deep: ['new'] } };
+    changeJson(JSON.stringify(next, null, 2));
+    fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
+    expect(basicField('Proxy IPs / CIDRs').value).toBe('9.9.9.9/32');
+    expect(basicField('Blocked Domains (Ad/Malware)').value).toBe(
+      'geosite:category-ads-all\ndomain:ads.example',
+    );
+    expect(updateSetting).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+    expect(generatedProfile()).toEqual(next);
+  });
+
+  it.each(['Basic rules', 'Advanced editor'])(
+    'preserves untouched null lists when generating from %s',
+    (tab) => {
+      renderSettings(JSON.stringify(nullableProfile));
+      const dialog = openEditor();
+      expect(dialog.queryByRole('alert')).toBeNull();
+      expect(
+        dialog.getAllByRole('textbox').map((field) => (field as HTMLTextAreaElement).value),
+      ).toEqual(['', '', '', '', '', '']);
+
+      fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
+      expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(nullableProfile);
+      fireEvent.click(screen.getByRole('tab', { name: tab }));
+      fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+
+      expect(generatedProfile()).toEqual(nullableProfile);
+      expect(screen.queryByRole('dialog')).toBeNull();
+    },
+  );
+
+  it('replaces only the edited null list with rules', () => {
+    renderSettings(routingLink(nullableProfile, 'add'));
+    openEditor();
+    fireEvent.change(basicField('Direct Domains (Bypass)'), {
+      target: { value: 'domain:local.example\n' },
+    });
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+
+    expect(routingRules().startsWith('happ://routing/add/')).toBe(true);
+    expect(generatedProfile()).toEqual({
+      ...nullableProfile,
+      DirectSites: ['domain:local.example'],
+    });
+  });
+
+  it('accepts setting a list to null in the advanced editor', () => {
+    renderSettings();
+    openEditor();
+    fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
+    const next = { ...profile, DirectSites: null };
+    changeJson(JSON.stringify(next));
+    fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
+    expect(basicField('Direct Domains (Bypass)').value).toBe('');
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+
+    expect(generatedProfile()).toEqual(next);
+  });
+
+  it.each(['{"Name":', '{"DirectSites": [42]}'])(
+    'retains invalid JSON %s until repaired',
+    (invalid) => {
+      const updateSetting = renderSettings();
+      openEditor();
+      fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
+      changeJson(invalid);
+
+      const generate = screen.getByRole('button', {
+        name: 'Generate Deeplink',
+      }) as HTMLButtonElement;
+      expect(generate.disabled).toBe(true);
+      expect(screen.getByRole('alert').textContent).toContain('arrays of strings');
+      fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
+      expect(
+        screen.getByRole('tab', { name: 'Advanced editor' }).getAttribute('aria-selected'),
+      ).toBe('true');
+      expect(jsonEditor().state.doc.toString()).toBe(invalid);
+      fireEvent.click(generate);
+      expect(updateSetting).not.toHaveBeenCalled();
+
+      const repaired = { ...profile, Name: 'Repaired', BlockIp: ['192.0.2.0/24'] };
+      changeJson(JSON.stringify(repaired));
+      expect(generate.disabled).toBe(false);
+      fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
+      expect(basicField('Blocked IPs / CIDRs').value).toBe('192.0.2.0/24');
+      fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+      expect(generatedProfile()).toEqual(repaired);
+    },
+  );
+
+  it('discards canceled edits and reloads the latest parent draft on reopening', () => {
+    const updateSetting = renderSettings();
+    openEditor();
+    fireEvent.change(basicField('Direct Domains (Bypass)'), {
+      target: { value: 'discard.example' },
+    });
+    fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
+    changeJson('{invalid');
+    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+    expect(updateSetting).not.toHaveBeenCalled();
+    expect(routingRules()).toBe(routingLink(profile));
+
+    const latest = { ...profile, DirectSites: ['latest.example'] };
+    fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
+      target: { value: JSON.stringify(latest) },
+    });
+    updateSetting.mockClear();
+    openEditor();
+    expect(screen.getByRole('tab', { name: 'Basic rules' }).getAttribute('aria-selected')).toBe(
+      'true',
+    );
+    expect(basicField('Direct Domains (Bypass)').value).toBe('latest.example');
+    expect(screen.queryByRole('alert')).toBeNull();
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+    expect(generatedProfile()).toEqual(latest);
+    expect(updateSetting).toHaveBeenCalledTimes(1);
+  });
+
+  it.each([
+    ['https://example.com/DEFAULT.DEEPLINK', 'Remote URLs cannot be loaded here'],
+    ['http://example.com/rules', 'not a valid Happ routing link'],
+    ['happ://routing/off', 'Routing is disabled'],
+    ['happ://routing/onadd/not-base64', 'not a valid Happ routing link'],
+    ['{"BlockIp":42}', 'not a valid Happ routing link'],
+  ])('blocks generation for unsupported current rules: %s', (input, message) => {
+    const updateSetting = renderSettings(input);
+    openEditor();
+
+    expect(screen.getByRole('alert').textContent).toContain(message);
+    const generate = screen.getByRole('button', { name: 'Generate Deeplink' }) as HTMLButtonElement;
+    expect(generate.disabled).toBe(true);
+    fireEvent.click(generate);
+    expect(updateSetting).not.toHaveBeenCalled();
+    expect(routingRules()).toBe(input);
+  });
+
+  it('starts a new custom profile when the current draft is blank', () => {
+    const updateSetting = renderSettings('');
+    openEditor();
+    expect(screen.getByRole('alert').textContent).toContain('No current rules');
+    fireEvent.change(basicField('Direct Domains (Bypass)'), {
+      target: { value: 'domain:local.example\n' },
+    });
+    expect(updateSetting).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+    expect(routingRules().startsWith('happ://routing/onadd/')).toBe(true);
+    expect(generatedProfile()).toEqual({
+      Name: 'Custom Rules',
+      GlobalProxy: 'true',
+      DirectSites: ['domain:local.example'],
+      DirectIp: [],
+      ProxySites: [],
+      ProxyIp: [],
+      BlockSites: [],
+      BlockIp: [],
+      DomainStrategy: 'IPIfNonMatch',
+    });
+  });
+
+  it('does not add absent list fields to an unchanged raw JSON profile', () => {
+    const minimal = { Name: 'Minimal', FutureSetting: profile.FutureSetting };
+    renderSettings(JSON.stringify(minimal));
+    openEditor();
+    fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
+    expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(minimal);
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+    expect(generatedProfile()).toEqual(minimal);
+  });
+});

+ 211 - 0
frontend/src/test/happ-settings-presets.test.tsx

@@ -0,0 +1,211 @@
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+
+import { AllSetting } from '@/models/setting';
+import HappSettingsContent from '@/pages/settings/HappSettingsContent';
+
+import { renderWithProviders } from './test-utils';
+
+const chinaProfile = {
+  Name: 'Bypass-CN',
+  GlobalProxy: 'true',
+  RouteOrder: 'block-proxy-direct',
+  RemoteDNSType: 'DoH',
+  RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
+  RemoteDNSIP: '1.1.1.1',
+  DomesticDNSType: 'DoH',
+  DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
+  DomesticDNSIP: '223.5.5.5',
+  DnsHosts: {
+    'cloudflare-dns.com': '1.1.1.1',
+    'dns.alidns.com': '223.5.5.5',
+  },
+  DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
+  DirectIp: [
+    'geoip:cn',
+    'geoip:private',
+    '127.0.0.0/8',
+    '10.0.0.0/8',
+    '172.16.0.0/12',
+    '192.168.0.0/16',
+    '169.254.0.0/16',
+    '224.0.0.0/4',
+    '255.255.255.255',
+  ],
+  ProxySites: [],
+  ProxyIp: [],
+  BlockSites: [],
+  BlockIp: [],
+  DomainStrategy: 'IPIfNonMatch',
+  FakeDNS: 'false',
+  UseChunkFiles: 'true',
+};
+
+function renderSettings(isMobile = false) {
+  const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
+
+  function SettingsHarness() {
+    const [allSetting, setAllSetting] = useState(() => {
+      const settings = new AllSetting();
+      settings.subRoutingRules = 'https://example.com/existing-routing';
+      return settings;
+    });
+
+    return (
+      <HappSettingsContent
+        allSetting={allSetting}
+        updateSetting={(patch) => {
+          updateSetting(patch);
+          setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
+        }}
+        isMobile={isMobile}
+        remoteSourceBadge={() => null}
+      />
+    );
+  }
+
+  renderWithProviders(<SettingsHarness />);
+  return updateSetting;
+}
+
+function openPresets() {
+  const select = screen.getByRole('combobox').closest('.ant-select');
+  if (!select) throw new Error('Routing preset select not found');
+  fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
+  return Array.from(
+    document.querySelectorAll(
+      '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
+    ),
+  );
+}
+
+function choosePreset(label: string) {
+  const option = openPresets().find((item) => item.textContent === label);
+  if (!option) throw new Error(`Routing preset '${label}' not found`);
+  fireEvent.click(option);
+}
+
+function routingRules() {
+  return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
+}
+
+function routingProfile(): Record<string, unknown> {
+  const prefix = 'happ://routing/onadd/';
+  const link = routingRules();
+  expect(link.startsWith(prefix)).toBe(true);
+  return JSON.parse(atob(link.slice(prefix.length))) as Record<string, unknown>;
+}
+
+describe('Happ routing preset controls', () => {
+  it('keeps selecting and toggling local until Apply updates the routing rules', () => {
+    const updateSetting = renderSettings();
+
+    choosePreset('China Direct (Bypass-CN)');
+    const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
+    expect(includeAdblock.getAttribute('aria-checked')).toBe('false');
+    fireEvent.click(includeAdblock);
+
+    expect(includeAdblock.getAttribute('aria-checked')).toBe('true');
+    expect(updateSetting).not.toHaveBeenCalled();
+    expect(routingRules()).toBe('https://example.com/existing-routing');
+
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
+    expect(routingProfile().BlockSites).toEqual(['geosite:category-ads-all']);
+  });
+
+  it.each([
+    ['Iran Bypass', 'Iran Bypass'],
+    ['China Direct (Bypass-CN)', 'Bypass-CN'],
+    ['Full Proxy', 'Global Proxy'],
+    ['Global Bypass Local Network', 'Global Bypass Local Network'],
+  ])('applies %s without AdBlock by default', (label, profileName) => {
+    renderSettings();
+    choosePreset(label);
+
+    expect(
+      screen.getByRole('switch', { name: 'Include AdBlock' }).getAttribute('aria-checked'),
+    ).toBe('false');
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(routingProfile()).toMatchObject({ Name: profileName, BlockSites: [] });
+  });
+
+  it('keeps LAN bypass separate from Full Proxy when switching presets', () => {
+    renderSettings();
+    choosePreset('Global Bypass Local Network');
+    fireEvent.click(screen.getByRole('switch', { name: 'Include AdBlock' }));
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(routingProfile()).toMatchObject({
+      Name: 'Global Bypass Local Network',
+      GlobalProxy: 'true',
+      DirectSites: ['geosite:private'],
+      DirectIp: ['geoip:private'],
+      BlockSites: ['geosite:category-ads-all'],
+    });
+
+    choosePreset('Full Proxy');
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(routingProfile()).toMatchObject({
+      Name: 'Global Proxy',
+      GlobalProxy: 'true',
+      DirectSites: [],
+      DirectIp: [],
+      BlockSites: ['geosite:category-ads-all'],
+    });
+  });
+
+  it('preserves China routing when opting in and clears AdBlock when reapplied after opting out', () => {
+    const updateSetting = renderSettings(true);
+    choosePreset('China Direct (Bypass-CN)');
+    const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
+    fireEvent.click(includeAdblock);
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(routingProfile()).toEqual({
+      ...chinaProfile,
+      BlockSites: ['geosite:category-ads-all'],
+    });
+    const withAdblock = routingRules();
+
+    fireEvent.click(includeAdblock);
+    expect(routingRules()).toBe(withAdblock);
+    expect(updateSetting).toHaveBeenCalledTimes(1);
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(updateSetting).toHaveBeenCalledTimes(2);
+    expect(routingProfile()).toEqual(chinaProfile);
+  });
+
+  it('offers only base presets so AdBlock cannot replace a country preset', () => {
+    renderSettings();
+
+    expect(openPresets().map((option) => option.textContent)).toEqual([
+      'Iran Bypass',
+      'China Direct (Bypass-CN)',
+      'Full Proxy',
+      'Global Bypass Local Network',
+      'Disable Routing (happ://routing/off)',
+    ]);
+  });
+
+  it('disables AdBlock for Off and applies the off link even after opting in', () => {
+    const updateSetting = renderSettings();
+    const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
+    fireEvent.click(includeAdblock);
+    choosePreset('Disable Routing (happ://routing/off)');
+
+    expect((includeAdblock as HTMLButtonElement).disabled).toBe(true);
+    expect(updateSetting).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
+
+    expect(updateSetting).toHaveBeenCalledExactlyOnceWith({
+      subRoutingRules: 'happ://routing/off',
+    });
+    expect(routingRules()).toBe('happ://routing/off');
+  });
+});

+ 84 - 0
frontend/src/test/incy-settings-content.test.tsx

@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent } from '@testing-library/react';
+
+import IncySettingsContent from '@/pages/settings/IncySettingsContent';
+import { AllSetting } from '@/models/setting';
+
+import { renderWithProviders } from './test-utils';
+
+function openTab(name: string) {
+  const tab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find((t) =>
+    (t.textContent ?? '').includes(name),
+  );
+  if (!tab) throw new Error(`tab '${name}' not found`);
+  fireEvent.click(tab);
+}
+
+function settingRow(title: string): HTMLElement {
+  const row = Array.from(document.querySelectorAll('li,div[class*="setting"]')).find((r) =>
+    (r.textContent ?? '').includes(title),
+  );
+  if (!row) throw new Error(`setting row '${title}' not found`);
+  return row as HTMLElement;
+}
+
+function selectFor(title: string): HTMLElement {
+  const select = settingRow(title).querySelector('.ant-select');
+  if (!select) throw new Error(`select for '${title}' not found`);
+  return select as HTMLElement;
+}
+
+function selectOption(title: string, option: string) {
+  const select = selectFor(title);
+  fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
+  const match = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
+    (o) => (o.textContent ?? '').trim() === option,
+  );
+  if (!match) throw new Error(`option '${option}' for '${title}' not found`);
+  fireEvent.click(match);
+}
+
+function render(subIncy: Partial<AllSetting> = {}) {
+  const updateSetting = vi.fn();
+  const allSetting = Object.assign(new AllSetting(), subIncy);
+  renderWithProviders(
+    <IncySettingsContent
+      allSetting={allSetting}
+      updateSetting={updateSetting}
+      isMobile={false}
+      remoteSourceBadge={() => null}
+    />,
+  );
+  return updateSetting;
+}
+
+describe('Incy app-management settings', () => {
+  // Every Incy switch and enum is tri-state: "Not set" must store the empty
+  // value so the panel sends no header and the subscriber's app choice wins.
+  it('stores the empty value for "Not set" so no header is emitted', () => {
+    const updateSetting = render({ subIncySortOrder: 'ping' });
+
+    openTab('App');
+    selectOption('Server sort order', 'Not set');
+
+    expect(updateSetting).toHaveBeenCalledWith({ subIncySortOrder: '' });
+  });
+
+  it('sends the documented per-app literal, not the Happ on/include spelling', () => {
+    const updateSetting = render();
+
+    openTab('Privacy');
+    selectOption('Per-app mode (Android)', 'On');
+
+    expect(updateSetting).toHaveBeenCalledWith({ subIncyPerAppEnable: '1' });
+  });
+
+  it('offers bypass and proxy for the per-app mode', () => {
+    const updateSetting = render();
+
+    openTab('Privacy');
+    selectOption('Per-app mode type', 'Listed apps bypass');
+
+    expect(updateSetting).toHaveBeenCalledWith({ subIncyPerAppMode: 'bypass' });
+  });
+});

+ 16 - 13
frontend/src/test/node-history-panel.test.tsx

@@ -37,18 +37,21 @@ describe('NodeHistoryPanel', () => {
 
     render(<NodeHistoryPanel node={{ id: 7 }} />);
 
-    await waitFor(() => expect(screen.getAllByRole('img')).toHaveLength(4));
-    expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
-      '40%',
-      '60%',
-      '512',
-      '200',
-    ]);
-    expect(plots.map((p) => p.scales.y.range())).toEqual([
-      [0, 100],
-      [0, 100],
-      [0, 512 * 1.1],
-      [0, 200 * 1.1],
-    ]);
+    // Sparkline updates its chart refs in a passive effect after the DOM commits.
+    await waitFor(() => {
+      expect(screen.getAllByRole('img')).toHaveLength(4);
+      expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
+        '40%',
+        '60%',
+        '512',
+        '200',
+      ]);
+      expect(plots.map((p) => p.scales.y.range())).toEqual([
+        [0, 100],
+        [0, 100],
+        [0, 512 * 1.1],
+        [0, 200 * 1.1],
+      ]);
+    });
   });
 });

+ 1 - 1
go.mod

@@ -13,6 +13,7 @@ require (
 	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
+	github.com/jackc/pgx/v5 v5.11.0
 	github.com/joho/godotenv v1.5.1
 	github.com/klauspost/compress v1.20.1
 	github.com/mattn/go-sqlite3 v1.14.52
@@ -65,7 +66,6 @@ require (
 	github.com/huin/goupnp v1.3.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
-	github.com/jackc/pgx/v5 v5.11.0 // indirect
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jackpal/go-nat-pmp v1.1.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect

+ 22 - 0
internal/amneziawg/outbound_test.go

@@ -310,3 +310,25 @@ func TestValidateAmneziaWGOutbound_DNSField(t *testing.T) {
 		}
 	}
 }
+
+// An outbound's S values are dictated by the remote server and received here on
+// Linux, so the iOS receive-buffer cap on inbounds must not refuse them.
+func TestValidateAmneziaWGOutbound_AcceptsRemotePaddingPastTheIOSCap(t *testing.T) {
+	m := validOutboundMapT(t)
+	m["s1"], m["s2"], m["s3"] = 2000, 3000, 4000
+	bs, err := json.Marshal(m)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil {
+		t.Fatalf("remote server padding S1=2000 S2=3000 S3=4000 refused: %v", err)
+	}
+
+	m["s1"] = 65536
+	if bs, err = json.Marshal(m); err != nil {
+		t.Fatal(err)
+	}
+	if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
+		t.Fatal("S1=65536 is past amneziawg-go's uint16 UAPI width and must be refused")
+	}
+}

+ 53 - 6
internal/amneziawg/params.go

@@ -64,7 +64,7 @@ func GenerateObfuscation31() Obfuscation31 {
 	}
 	// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
 	// rejects header protection unless every S1-S4 is >= 12.
-	o.S3 = randInt(12, 55) // cookie padding (max 64)
+	o.S3 = randInt(12, 55) // cookie padding
 	o.S4 = randInt(12, 27) // transport padding (max 32)
 
 	h := generateHValues()
@@ -134,6 +134,31 @@ func generateHValues() [4]string {
 	return out
 }
 
+// Padded handshake messages (148+S1, 92+S2, 64+S3 bytes) must fit the smallest receive
+// buffer amneziawg-go has: MaxSegmentSize 1700 on iOS (device/queueconstants_ios.go).
+const (
+	maxServerS1 = 1700 - 148
+	maxServerS2 = 1700 - 92
+	maxServerS3 = 1700 - 64
+)
+
+// ValidateServerObfuscation adds the receive-buffer bounds to ValidateObfuscation:
+// an inbound's peers may be iOS clients, which cannot receive a larger handshake.
+func ValidateServerObfuscation(o Obfuscation31) error {
+	if err := ValidateObfuscation(o); err != nil {
+		return err
+	}
+	for _, f := range []struct {
+		name   string
+		v, max int
+	}{{"S1", o.S1, maxServerS1}, {"S2", o.S2, maxServerS2}, {"S3", o.S3, maxServerS3}} {
+		if f.v > f.max {
+			return fmt.Errorf("invalid %s value %d (must be 0..%d so every client can receive it)", f.name, f.v, f.max)
+		}
+	}
+	return nil
+}
+
 // ValidateObfuscation rejects malformed parameters before they are saved, so
 // a bad manual entry can't break the embedded amneziawg-go device's own
 // UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
@@ -144,8 +169,8 @@ func ValidateObfuscation(o Obfuscation31) error {
 	if o.Jmin > o.Jmax {
 		return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
 	}
-	// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s4 as uint16
-	// (device/uapi.go); a wider value makes IpcSet reject the whole device.
+	// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s3 as uint16 (device/uapi.go);
+	// a wider value makes IpcSet reject the whole device.
 	for _, f := range []struct {
 		name string
 		v    int
@@ -156,6 +181,7 @@ func ValidateObfuscation(o Obfuscation31) error {
 		{"Jmax", o.Jmax, math.MaxUint32},
 		{"S1", o.S1, math.MaxUint16},
 		{"S2", o.S2, math.MaxUint16},
+		{"S3", o.S3, math.MaxUint16},
 	} {
 		if int64(f.v) < 0 || int64(f.v) > f.max {
 			return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max)
@@ -166,9 +192,6 @@ func ValidateObfuscation(o Obfuscation31) error {
 			return fmt.Errorf("invalid I%d: %w", i+1, err)
 		}
 	}
-	if o.S3 < 0 || o.S3 > 64 {
-		return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3)
-	}
 	if o.S4 < 0 || o.S4 > 32 {
 		return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
 	}
@@ -180,6 +203,9 @@ func ValidateObfuscation(o Obfuscation31) error {
 			return fmt.Errorf("invalid H%d: %w", i+1, err)
 		}
 	}
+	if err := validateHNoOverlap([4]string{o.H1, o.H2, o.H3, o.H4}); err != nil {
+		return err
+	}
 	if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
 		return err
 	}
@@ -378,6 +404,27 @@ func validateUintRange(v string, minAllowed int64) error {
 	return nil
 }
 
+// validateHNoOverlap mirrors amneziawg-go's "headers must not overlap" (device/uapi.go).
+// A blank Hn is never sent, so the engine keeps its default: WireGuard's own type n.
+func validateHNoOverlap(hs [4]string) error {
+	var lo, hi [4]int64
+	for i, h := range hs {
+		l, u, ok := parseUintRange(h)
+		if !ok {
+			l, u = int64(i+1), int64(i+1)
+		}
+		lo[i], hi[i] = l, u
+	}
+	for i := range 4 {
+		for j := i + 1; j < 4; j++ {
+			if lo[i] <= hi[j] && lo[j] <= hi[i] {
+				return fmt.Errorf("invalid H%d/H%d: %d-%d and %d-%d overlap", i+1, j+1, lo[i], hi[i], lo[j], hi[j])
+			}
+		}
+	}
+	return nil
+}
+
 // parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
 // or non-numeric. Bounds are NOT checked here.
 func parseUintRange(v string) (lo, hi int64, ok bool) {

+ 63 - 4
internal/amneziawg/params_test.go

@@ -142,9 +142,9 @@ func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
 
 func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
 	o := validObfuscation()
-	o.S3 = 65
+	o.S3 = 65536
 	if err := ValidateObfuscation(o); err == nil {
-		t.Fatal("S3 > 64 must be rejected")
+		t.Fatal("S3 past uint16 must be rejected: amneziawg-go's UAPI parser refuses it")
 	}
 	o = validObfuscation()
 	o.S4 = 33
@@ -158,6 +158,17 @@ func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
 	}
 }
 
+// Amnezia Premium ships S3=1045; 1636 is the largest cookie padding every platform can receive.
+func TestValidateServerObfuscationAcceptsLargeS3(t *testing.T) {
+	for _, s3 := range []int{1045, 1636} {
+		o := validObfuscation()
+		o.S3 = s3
+		if err := ValidateServerObfuscation(o); err != nil {
+			t.Fatalf("S3=%d must be accepted: %v", s3, err)
+		}
+	}
+}
+
 func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
 	for field, set := range map[string]func(o *Obfuscation31){
 		"S1": func(o *Obfuscation31) { o.S1 = 11 },
@@ -415,7 +426,7 @@ func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) {
 }
 
 // TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths
-// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4.
+// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s3.
 func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
 	base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
 	tests := []struct {
@@ -439,7 +450,55 @@ func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
 		})
 	}
 	if err := ValidateObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 65535, S2: 30, S3: 20, S4: 20}); err != nil {
-		t.Fatalf("S1 at the uint16 maximum must stay valid: %v", err)
+		t.Fatalf("S1 at the uint16 maximum must stay valid for an outbound: %v", err)
+	}
+}
+
+// An inbound's handshakes must fit iOS's 1700-byte buffer: 148+S1, 92+S2 and 64+S3.
+func TestValidateServerObfuscationBoundsHandshakesByTheIOSBuffer(t *testing.T) {
+	base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
+	for name, mut := range map[string]func(*Obfuscation31){
+		"S1 init over 1700 bytes":     func(o *Obfuscation31) { o.S1 = 1553 },
+		"S2 response over 1700 bytes": func(o *Obfuscation31) { o.S2 = 1609 },
+		"S3 cookie over 1700 bytes":   func(o *Obfuscation31) { o.S3 = 1637 },
+	} {
+		o := base
+		mut(&o)
+		if err := ValidateServerObfuscation(o); err == nil {
+			t.Fatalf("%s: an iOS client could never receive it, so the inbound must not save", name)
+		}
+	}
+	// 148+1552 and 92+1608 are exactly 1700; Amnezia Premium ships S1=284 S2=659.
+	for _, s := range [][2]int{{1552, 30}, {20, 1608}, {284, 659}} {
+		if err := ValidateServerObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: s[0], S2: s[1], S3: 20, S4: 20}); err != nil {
+			t.Fatalf("S1=%d S2=%d must stay valid: %v", s[0], s[1], err)
+		}
+	}
+}
+
+// amneziawg-go refuses the whole device when H1-H4 overlap ("headers must not overlap",
+// device/uapi.go); 1-4 alone are legal and the engine's own default.
+func TestValidateObfuscationHOverlap(t *testing.T) {
+	base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
+	reject := [][4]string{
+		{"100-200", "150-300", "400", "500"},
+		{"7", "7", "8", "9"},
+		{"3", "", "", ""}, // blank H3 keeps the engine default 3
+	}
+	for _, h := range reject {
+		o := base
+		o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
+		if err := ValidateObfuscation(o); err == nil {
+			t.Fatalf("H=%v overlaps, amneziawg-go rejects it, so the inbound must not save", h)
+		}
+	}
+	accept := [][4]string{{"1", "2", "3", "4"}, {"", "", "", ""}, {"5-10", "11-20", "21", "22-30"}}
+	for _, h := range accept {
+		o := base
+		o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
+		if err := ValidateObfuscation(o); err != nil {
+			t.Fatalf("H=%v must be accepted: %v", h, err)
+		}
 	}
 }
 

+ 11 - 0
internal/amneziawgnet/device_test.go

@@ -584,6 +584,17 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
 		{"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "<b ff00><r 10>" }},
 		{"I1 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<t><rc 5>" }},
 		{"I1 no tags at all", func(o *amneziawg.Obfuscation31) { o.I1 = "plain text" }},
+		{"H ranges overlap", func(o *amneziawg.Obfuscation31) { o.H1, o.H2 = "100-200", "150-300" }},
+		{"H1 equals the blank H3 default", func(o *amneziawg.Obfuscation31) { o.H1 = "3" }},
+		{"H1-H4 = WireGuard's 1-4", func(o *amneziawg.Obfuscation31) { o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4" }},
+		// Separate cases: 1552+56 == 1608, so both maxima together trip the S1/S2 size rule.
+		{"S1 and S3 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S1, o.S3 = 1552, 1636 }},
+		{"S2 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S2 = 1608 }},
+		{"Amnezia Premium set", func(o *amneziawg.Obfuscation31) {
+			o.S1, o.S2, o.S3, o.S4 = 284, 659, 1045, 12
+			o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4"
+			o.HeaderProtectionKey = "A2lG0Jm3m8u1WJt0qg3d7V6Qx8cFvH9pL1nR4sT6yZ0="
+		}},
 	}
 
 	for i, tc := range cases {

+ 29 - 8
internal/amneziawgnet/dns.go

@@ -135,21 +135,42 @@ func flushTunnelDNSCacheForTag(tag string) {
 	}
 }
 
-// exchangeTunnelDNSWithFallback queries A and/or AAAA depending on the local
-// address families configured on the device stack.
-func exchangeTunnelDNSWithFallback(ctx context.Context, conn *gonet.UDPConn, addrs []netip.Addr, host string) (netip.Addr, error) {
+// dnsQueryTypesFor asks only for families the tunnel can dial, so a v4-only tunnel
+// never caches an unroutable AAAA answer (#6570). No addresses keeps A then AAAA.
+func dnsQueryTypesFor(addrs []netip.Addr) []dnsmessage.Type {
 	hasV4 := deviceHasV4(addrs)
 	hasV6 := deviceHasV6(addrs)
+	switch {
+	case hasV4 && hasV6:
+		return []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
+	case hasV6:
+		return []dnsmessage.Type{dnsmessage.TypeAAAA}
+	case hasV4:
+		return []dnsmessage.Type{dnsmessage.TypeA}
+	default:
+		return []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
+	}
+}
 
-	// If the tunnel is IPv6-only, query AAAA first; else query A first.
-	types := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
-	if hasV6 && !hasV4 {
-		types = []dnsmessage.Type{dnsmessage.TypeAAAA, dnsmessage.TypeA}
+// tunnelSupportsAddr reports whether the device stack has a local address in
+// the same family as ip (IPv4-mapped IPv6 counts as IPv4).
+func tunnelSupportsAddr(addrs []netip.Addr, ip netip.Addr) bool {
+	if !ip.IsValid() {
+		return false
+	}
+	if ip.Is4() || ip.Is4In6() {
+		return deviceHasV4(addrs)
 	}
+	return deviceHasV6(addrs)
+}
+
+// exchangeTunnelDNSWithFallback returns the first answer among the families the
+// device stack can route.
+func exchangeTunnelDNSWithFallback(ctx context.Context, conn *gonet.UDPConn, addrs []netip.Addr, host string) (netip.Addr, error) {
+	types := dnsQueryTypesFor(addrs)
 
 	var firstErr error
 	for _, qType := range types {
-		// Skip AAAA if device has no IPv6 capability and has IPv4, unless A failed.
 		addr, err := exchangeTunnelDNSQuery(ctx, conn, host, qType)
 		if err == nil {
 			return addr, nil

+ 107 - 0
internal/amneziawgnet/dns_family_test.go

@@ -0,0 +1,107 @@
+package amneziawgnet
+
+import (
+	"net/netip"
+	"testing"
+
+	"golang.org/x/net/dns/dnsmessage"
+)
+
+func TestDNSQueryTypesFor(t *testing.T) {
+	v4 := netip.MustParseAddr("10.8.0.2")
+	v6 := netip.MustParseAddr("2001:db8::2")
+	mapped := netip.MustParseAddr("::ffff:10.8.0.2")
+
+	cases := []struct {
+		name  string
+		addrs []netip.Addr
+		want  []dnsmessage.Type
+	}{
+		{
+			name:  "v4-only",
+			addrs: []netip.Addr{v4},
+			want:  []dnsmessage.Type{dnsmessage.TypeA},
+		},
+		{
+			name:  "v6-only",
+			addrs: []netip.Addr{v6},
+			want:  []dnsmessage.Type{dnsmessage.TypeAAAA},
+		},
+		{
+			name:  "dual-stack prefers A then AAAA",
+			addrs: []netip.Addr{v4, v6},
+			want:  []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA},
+		},
+		{
+			name:  "empty falls back to A then AAAA",
+			addrs: nil,
+			want:  []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA},
+		},
+		{
+			name:  "v4-mapped alone is not dual-stack",
+			addrs: []netip.Addr{mapped},
+			want:  []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := dnsQueryTypesFor(tc.addrs)
+			if len(got) != len(tc.want) {
+				t.Fatalf("dnsQueryTypesFor(%v) = %v, want %v", tc.addrs, got, tc.want)
+			}
+			for i := range got {
+				if got[i] != tc.want[i] {
+					t.Fatalf("dnsQueryTypesFor(%v) = %v, want %v", tc.addrs, got, tc.want)
+				}
+			}
+		})
+	}
+}
+
+func TestTunnelSupportsAddr(t *testing.T) {
+	v4 := netip.MustParseAddr("10.8.0.2")
+	v6 := netip.MustParseAddr("2001:db8::2")
+	dest4 := netip.MustParseAddr("8.8.8.8")
+	dest6 := netip.MustParseAddr("2001:4860:4860::8888")
+	mappedDest := netip.MustParseAddr("::ffff:8.8.8.8")
+
+	if !tunnelSupportsAddr([]netip.Addr{v4}, dest4) {
+		t.Error("v4 tunnel should dial IPv4")
+	}
+	if tunnelSupportsAddr([]netip.Addr{v4}, dest6) {
+		t.Error("v4-only tunnel must not dial IPv6")
+	}
+	if !tunnelSupportsAddr([]netip.Addr{v6}, dest6) {
+		t.Error("v6 tunnel should dial IPv6")
+	}
+	if tunnelSupportsAddr([]netip.Addr{v6}, dest4) {
+		t.Error("v6-only tunnel must not dial IPv4")
+	}
+	if !tunnelSupportsAddr([]netip.Addr{v4, v6}, dest4) || !tunnelSupportsAddr([]netip.Addr{v4, v6}, dest6) {
+		t.Error("dual-stack tunnel should dial both families")
+	}
+	if !tunnelSupportsAddr([]netip.Addr{v4}, mappedDest) {
+		t.Error("v4 tunnel should treat IPv4-mapped destinations as IPv4")
+	}
+	if tunnelSupportsAddr(nil, dest4) {
+		t.Error("empty address list should not claim support")
+	}
+}
+
+func TestSocksTargetResolveTunnelVia_RejectsWrongFamilyLiteral(t *testing.T) {
+	dev := &Device{localAddrs: []netip.Addr{netip.MustParseAddr("10.8.0.2")}}
+	target := socksTarget{ip: netip.MustParseAddr("2001:4860:4860::8888"), port: 443}
+	_, err := target.resolveTunnelVia("", "awg", dev)
+	if err == nil {
+		t.Fatal("expected error dialing IPv6 literal on v4-only tunnel")
+	}
+
+	okTarget := socksTarget{ip: netip.MustParseAddr("8.8.8.8"), port: 443}
+	got, err := okTarget.resolveTunnelVia("", "awg", dev)
+	if err != nil {
+		t.Fatalf("v4 literal on v4 tunnel: %v", err)
+	}
+	if got.String() != "8.8.8.8:443" {
+		t.Fatalf("got %s", got)
+	}
+}

+ 7 - 0
internal/amneziawgnet/egress.go

@@ -360,6 +360,9 @@ func (t socksTarget) String() string {
 // Domain targets resolve via the tunnel; reply-side helper must not be used here.
 func (t socksTarget) resolveTunnelVia(dnsServer, tag string, dev *Device) (netip.AddrPort, error) {
 	if t.ip.IsValid() {
+		if !tunnelSupportsAddr(dev.LocalAddresses(), t.ip) {
+			return netip.AddrPort{}, fmt.Errorf("tunnel has no route for %s (device addresses %v)", t.ip, dev.LocalAddresses())
+		}
 		return netip.AddrPortFrom(t.ip, t.port), nil
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
@@ -523,6 +526,10 @@ func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPC
 	if sess, ok := s.m[dst]; ok {
 		return sess
 	}
+	if !tunnelSupportsAddr(dev.LocalAddresses(), dst.Addr()) {
+		logger.Warningf("amneziawgnet: egress %q: dial udp %s: tunnel has no route for address family (device addresses %v)", tag, dst, dev.LocalAddresses())
+		return nil
+	}
 	raddr := tcpip.FullAddress{
 		NIC:  1,
 		Addr: tcpip.AddrFromSlice(dst.Addr().AsSlice()),

+ 90 - 0
internal/database/client_weekly_renew_migration_test.go

@@ -0,0 +1,90 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestClientWeeklyRenewMigration(t *testing.T) {
+	for _, nullable := range []bool{false, true} {
+		name := "missing columns"
+		if nullable {
+			name = "nullable columns and configured weekday"
+		}
+		t.Run(name, func(t *testing.T) {
+			path := filepath.Join(t.TempDir(), "x-ui.db")
+			legacy, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
+			if err != nil {
+				t.Fatal(err)
+			}
+			column := ""
+			if nullable {
+				column = ", reset_weekday INTEGER"
+			}
+			for _, ddl := range []string{
+				"CREATE TABLE clients (id INTEGER PRIMARY KEY, email TEXT, reset INTEGER, reset_day INTEGER, reset_max INTEGER, expiry_time BIGINT" + column + ")",
+				"CREATE TABLE client_traffics (id INTEGER PRIMARY KEY, email TEXT, reset INTEGER, reset_day INTEGER, reset_max INTEGER, reset_count INTEGER, expiry_time BIGINT, up BIGINT, down BIGINT" + column + ")",
+				"INSERT INTO clients (id,email,reset,reset_day,reset_max,expiry_time) VALUES (1,'legacy',30,15,3,1893456000000)",
+				"INSERT INTO client_traffics (id,email,reset,reset_day,reset_max,reset_count,expiry_time,up,down) VALUES (1,'legacy',30,15,3,2,1893456000000,111,222)",
+			} {
+				if err := legacy.Exec(ddl).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			if nullable {
+				if err := legacy.Exec("INSERT INTO clients (id,email,reset_weekday) VALUES (2,'weekly',2)").Error; err != nil {
+					t.Fatal(err)
+				}
+				if err := legacy.Exec("INSERT INTO client_traffics (id,email,reset_weekday) VALUES (2,'weekly',2)").Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			handle, err := legacy.DB()
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := handle.Close(); err != nil {
+				t.Fatal(err)
+			}
+			if err := InitDB(path); err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { _ = CloseDB() })
+			for _, table := range []string{"clients", "client_traffics"} {
+				var nulls int64
+				if err := GetDB().Table(table).Where("reset_weekday IS NULL").Count(&nulls).Error; err != nil || nulls != 0 {
+					t.Fatalf("%s NULL weekdays/error = %d/%v", table, nulls, err)
+				}
+				if nullable {
+					var weekday int
+					if err := GetDB().Table(table).Where("email = ?", "weekly").Pluck("reset_weekday", &weekday).Error; err != nil || weekday != 2 {
+						t.Fatalf("%s configured weekday/error = %d/%v", table, weekday, err)
+					}
+				}
+			}
+			var client model.ClientRecord
+			if err := GetDB().Where("email = ?", "legacy").First(&client).Error; err != nil {
+				t.Fatal(err)
+			}
+			var traffic xray.ClientTraffic
+			if err := GetDB().Where("email = ?", "legacy").First(&traffic).Error; err != nil {
+				t.Fatal(err)
+			}
+			if client.Reset != 30 || client.ResetDay != 15 || client.ResetMax != 3 || client.ResetWeekday != 0 || client.ExpiryTime != 1893456000000 {
+				t.Fatalf("legacy client changed: %+v", client)
+			}
+			if traffic.Reset != 30 || traffic.ResetDay != 15 || traffic.ResetMax != 3 || traffic.ResetCount != 2 || traffic.ResetWeekday != 0 || traffic.ExpiryTime != 1893456000000 || traffic.Up != 111 || traffic.Down != 222 {
+				t.Fatalf("legacy traffic changed: %+v", traffic)
+			}
+			if err := migrateClientResetWeekdayColumns(); err != nil {
+				t.Fatal(err)
+			}
+		})
+	}
+}

+ 118 - 0
internal/database/client_weekly_renew_postgres_test.go

@@ -0,0 +1,118 @@
+package database
+
+import (
+	"fmt"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestClientWeeklyRenewMigration_Postgres(t *testing.T) {
+	dsn := strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
+	if dsn == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the weekly migration test")
+	}
+	admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	handle, err := admin.DB()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = handle.Close() })
+	for _, nullable := range []bool{false, true} {
+		t.Run(fmt.Sprintf("nullable_%t", nullable), func(t *testing.T) {
+			schema := fmt.Sprintf("weekly_renew_%d", time.Now().UnixNano())
+			if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() {
+				_ = CloseDB()
+				if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil {
+					t.Error(err)
+				}
+			})
+			scoped := dsn + " search_path=" + schema
+			if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
+				u, err := url.Parse(dsn)
+				if err != nil {
+					t.Fatal(err)
+				}
+				query := u.Query()
+				query.Set("search_path", schema)
+				u.RawQuery = query.Encode()
+				scoped = u.String()
+			}
+			t.Setenv("XUI_DB_DSN", scoped)
+			legacy, err := gorm.Open(postgres.Open(scoped), &gorm.Config{})
+			if err != nil {
+				t.Fatal(err)
+			}
+			legacyHandle, err := legacy.DB()
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { _ = legacyHandle.Close() })
+			column := ""
+			if nullable {
+				column = ", reset_weekday INTEGER"
+			}
+			for _, ddl := range []string{
+				"CREATE TABLE clients (id BIGSERIAL PRIMARY KEY, email TEXT, reset INTEGER, reset_day INTEGER, reset_max INTEGER, expiry_time BIGINT" + column + ")",
+				"CREATE TABLE client_traffics (id BIGSERIAL PRIMARY KEY, email TEXT, reset INTEGER, reset_day INTEGER, reset_max INTEGER, reset_count INTEGER, expiry_time BIGINT, up BIGINT, down BIGINT" + column + ")",
+				"INSERT INTO clients (email,reset,reset_day,reset_max,expiry_time) VALUES ('legacy',30,15,3,1893456000000)",
+				"INSERT INTO client_traffics (email,reset,reset_day,reset_max,reset_count,expiry_time,up,down) VALUES ('legacy',30,15,3,2,1893456000000,111,222)",
+			} {
+				if err := legacy.Exec(ddl).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			if nullable {
+				if err := legacy.Exec("INSERT INTO clients (email,reset_weekday) VALUES ('weekly',2)").Error; err != nil {
+					t.Fatal(err)
+				}
+				if err := legacy.Exec("INSERT INTO client_traffics (email,reset_weekday) VALUES ('weekly',2)").Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			if err := InitDB(""); err != nil {
+				t.Fatal(err)
+			}
+			for _, table := range []string{"clients", "client_traffics"} {
+				var nulls int64
+				if err := GetDB().Table(table).Where("reset_weekday IS NULL").Count(&nulls).Error; err != nil || nulls != 0 {
+					t.Fatalf("%s NULL weekdays/error = %d/%v", table, nulls, err)
+				}
+				if nullable {
+					var weekday int
+					if err := GetDB().Table(table).Where("email = ?", "weekly").Pluck("reset_weekday", &weekday).Error; err != nil || weekday != 2 {
+						t.Fatalf("%s weekday/error = %d/%v, want 2/nil", table, weekday, err)
+					}
+				}
+			}
+			var client model.ClientRecord
+			if err := GetDB().Where("email = ?", "legacy").First(&client).Error; err != nil {
+				t.Fatal(err)
+			}
+			var traffic xray.ClientTraffic
+			if err := GetDB().Where("email = ?", "legacy").First(&traffic).Error; err != nil {
+				t.Fatal(err)
+			}
+			if client.ResetWeekday != 0 || client.Reset != 30 || client.ResetDay != 15 || client.ResetMax != 3 || client.ExpiryTime != 1893456000000 || traffic.ResetWeekday != 0 || traffic.Reset != 30 || traffic.ResetDay != 15 || traffic.ResetMax != 3 || traffic.ResetCount != 2 || traffic.Up != 111 || traffic.Down != 222 || traffic.ExpiryTime != client.ExpiryTime {
+				t.Fatalf("legacy PostgreSQL limits changed: client=%+v traffic=%+v", client, traffic)
+			}
+			if err := migrateClientResetWeekdayColumns(); err != nil {
+				t.Fatal(err)
+			}
+		})
+	}
+}

+ 14 - 0
internal/database/db.go

@@ -176,6 +176,9 @@ func initModels() error {
 	if err := migrateClientTrafficResetColumns(); err != nil {
 		return err
 	}
+	if err := migrateClientResetWeekdayColumns(); err != nil {
+		return err
+	}
 	if err := migrateSyncOrphanColumns(); err != nil {
 		return err
 	}
@@ -355,6 +358,17 @@ func migrateClientTrafficResetColumns() error {
 	return nil
 }
 
+// Existing clients keep weekly renewal disabled, including nullable columns
+// left by an earlier ALTER TABLE; configured nonzero weekdays are preserved.
+func migrateClientResetWeekdayColumns() error {
+	for _, table := range []string{"clients", "client_traffics"} {
+		if err := db.Table(table).Where("reset_weekday IS NULL").UpdateColumn("reset_weekday", 0).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 {

+ 67 - 0
internal/database/dbtest/dbtest.go

@@ -0,0 +1,67 @@
+// Package dbtest opens throwaway panel databases for tests. Migrating a new
+// SQLite file costs ~850ms under -race; copying a migrated template ~130ms.
+package dbtest
+
+import (
+	"os"
+	"path/filepath"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+var migrated struct {
+	once sync.Once
+	data []byte
+	err  error
+}
+
+// InitDB opens a new, fully migrated panel database at path and closes it when
+// t ends. Reopen an existing file with database.InitDB instead.
+func InitDB(t testing.TB, path string) {
+	t.Helper()
+	if config.GetDBKind() != "postgres" {
+		if _, err := os.Stat(path); err == nil {
+			t.Fatalf("dbtest.InitDB would overwrite existing %s; reopen it with database.InitDB", path)
+		}
+		data, err := migratedTemplate()
+		if err != nil {
+			t.Fatalf("build template database: %v", err)
+		}
+		if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+			t.Fatalf("create database dir: %v", err)
+		}
+		if err := os.WriteFile(path, data, 0o600); err != nil {
+			t.Fatalf("copy template database: %v", err)
+		}
+	}
+	if err := database.InitDB(path); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func migratedTemplate() ([]byte, error) {
+	migrated.once.Do(func() {
+		dir, err := os.MkdirTemp("", "xui-dbtest-")
+		if err != nil {
+			migrated.err = err
+			return
+		}
+		defer os.RemoveAll(dir)
+		path := filepath.Join(dir, "template.db")
+		if err := database.InitDB(path); err != nil {
+			migrated.err = err
+			return
+		}
+		// Closing the last connection checkpoints the WAL into the main file.
+		if err := database.CloseDB(); err != nil {
+			migrated.err = err
+			return
+		}
+		migrated.data, migrated.err = os.ReadFile(path)
+	})
+	return migrated.data, migrated.err
+}

+ 36 - 0
internal/database/dbtest/dbtest_test.go

@@ -0,0 +1,36 @@
+package dbtest
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestInitDBGivesEachTestItsOwnDatabase(t *testing.T) {
+	t.Run("first test writes", func(t *testing.T) {
+		InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
+		if err := database.GetDB().Create(&model.Setting{Key: "dbtestProbe", Value: "first"}).Error; err != nil {
+			t.Fatalf("write probe: %v", err)
+		}
+	})
+
+	t.Run("next test starts clean", func(t *testing.T) {
+		InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
+		var leaked int64
+		if err := database.GetDB().Model(&model.Setting{}).Where("key = ?", "dbtestProbe").Count(&leaked).Error; err != nil {
+			t.Fatalf("count probe: %v", err)
+		}
+		if leaked != 0 {
+			t.Fatalf("database holds %d probe rows written by the previous test; copies must not share state", leaked)
+		}
+		var admins int64
+		if err := database.GetDB().Model(&model.User{}).Count(&admins).Error; err != nil {
+			t.Fatalf("count users: %v", err)
+		}
+		if admins != 1 {
+			t.Fatalf("users = %d, want the 1 seeded admin a fresh install has", admins)
+		}
+	})
+}

+ 30 - 0
internal/database/model/client_renewal_merge_test.go

@@ -0,0 +1,30 @@
+package model
+
+import "testing"
+
+func TestMergeClientRecordRenewalModes(t *testing.T) {
+	for _, tt := range []struct {
+		name                string
+		existing, incoming  ClientRecord
+		reset, day, weekday int
+	}{
+		{"newer weekly replaces interval", ClientRecord{Reset: 7, UpdatedAt: 1}, ClientRecord{ResetWeekday: 3, UpdatedAt: 2}, 0, 0, 3},
+		{"newer weekly replaces legacy monthly", ClientRecord{Reset: 7, ResetDay: 1, UpdatedAt: 1}, ClientRecord{ResetWeekday: 3, UpdatedAt: 2}, 0, 0, 3},
+		{"newer interval replaces weekly", ClientRecord{ResetWeekday: 3, UpdatedAt: 1}, ClientRecord{Reset: 7, UpdatedAt: 2}, 7, 0, 0},
+		{"newer monthly replaces weekly", ClientRecord{ResetWeekday: 3, UpdatedAt: 1}, ClientRecord{Reset: 7, ResetDay: 1, UpdatedAt: 2}, 7, 1, 0},
+		{"older weekly cannot fill interval zeros", ClientRecord{Reset: 7, UpdatedAt: 2}, ClientRecord{ResetWeekday: 3, UpdatedAt: 1}, 7, 0, 0},
+		{"older interval cannot fill weekly zeros", ClientRecord{ResetWeekday: 3, UpdatedAt: 2}, ClientRecord{Reset: 7, UpdatedAt: 1}, 0, 0, 3},
+		{"older monthly cannot fill weekly zeros", ClientRecord{ResetWeekday: 3, UpdatedAt: 2}, ClientRecord{ResetDay: 1, UpdatedAt: 1}, 0, 0, 3},
+		{"empty newer snapshot preserves weekly", ClientRecord{ResetWeekday: 3, UpdatedAt: 1}, ClientRecord{UpdatedAt: 2}, 0, 0, 3},
+		{"unset existing accepts weekly", ClientRecord{UpdatedAt: 2}, ClientRecord{ResetWeekday: 3, UpdatedAt: 1}, 0, 0, 3},
+		{"legacy monthly merging is unchanged", ClientRecord{Reset: 7, UpdatedAt: 1}, ClientRecord{ResetDay: 1, UpdatedAt: 2}, 7, 1, 0},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			tt.existing.ResetMax = 4
+			MergeClientRecord(&tt.existing, &tt.incoming)
+			if tt.existing.Reset != tt.reset || tt.existing.ResetDay != tt.day || tt.existing.ResetWeekday != tt.weekday || tt.existing.ResetMax != 4 {
+				t.Fatalf("merged schedule/cap = %d/%d/%d/%d, want %d/%d/%d/4", tt.existing.Reset, tt.existing.ResetDay, tt.existing.ResetWeekday, tt.existing.ResetMax, tt.reset, tt.day, tt.weekday)
+			}
+		})
+	}
+}

+ 48 - 21
internal/database/model/model.go

@@ -895,18 +895,19 @@ type Client struct {
 	ForwardedPorts      string           `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
 	Secret              string           `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
 	AdTag               string           `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
-	Email               string           `json:"email"`                        // Client email identifier
-	LimitIP             int              `json:"limitIp"`                      // IP limit for this client
-	TotalGB             int64            `json:"totalGB" form:"totalGB"`       // Total traffic limit in GB
-	ExpiryTime          int64            `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
-	Enable              bool             `json:"enable" form:"enable"`         // Whether the client is enabled
-	TgID                int64            `json:"tgId" form:"tgId"`             // Telegram user ID for notifications
-	SubID               string           `json:"subId" form:"subId"`           // Subscription identifier
-	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
+	Email               string           `json:"email"`                            // Client email identifier
+	LimitIP             int              `json:"limitIp"`                          // IP limit for this client
+	TotalGB             int64            `json:"totalGB" form:"totalGB"`           // Total traffic limit in GB
+	ExpiryTime          int64            `json:"expiryTime" form:"expiryTime"`     // Expiration timestamp
+	Enable              bool             `json:"enable" form:"enable"`             // Whether the client is enabled
+	TgID                int64            `json:"tgId" form:"tgId"`                 // Telegram user ID for notifications
+	SubID               string           `json:"subId" form:"subId"`               // Subscription identifier
+	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 disables monthly renewal
+	ResetWeekday        int              `json:"resetWeekday" form:"resetWeekday"` // Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal
+	ResetMax            int              `json:"resetMax" form:"resetMax"`         // Max auto-renew count, 0 = unlimited
 	// 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"`
@@ -942,6 +943,7 @@ type ClientRecord struct {
 	Comment         string `json:"comment"`
 	Reset           int    `json:"reset" gorm:"default:0"`
 	ResetDay        int    `json:"resetDay" gorm:"column:reset_day;default:0"`
+	ResetWeekday    int    `json:"resetWeekday" gorm:"column:reset_weekday;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"`
@@ -1154,6 +1156,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		Comment:         c.Comment,
 		Reset:           c.Reset,
 		ResetDay:        c.ResetDay,
+		ResetWeekday:    c.ResetWeekday,
 		ResetMax:        c.ResetMax,
 		TrafficReset:    c.TrafficReset,
 		TrafficResetDay: c.TrafficResetDay,
@@ -1212,6 +1215,7 @@ func (r *ClientRecord) ToClient() *Client {
 		Comment:         r.Comment,
 		Reset:           r.Reset,
 		ResetDay:        r.ResetDay,
+		ResetWeekday:    r.ResetWeekday,
 		ResetMax:        r.ResetMax,
 		TrafficReset:    r.TrafficReset,
 		TrafficResetDay: r.TrafficResetDay,
@@ -1377,16 +1381,39 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.TgID = incoming.TgID
 		}
 	}
-	if existing.Reset != incoming.Reset && incoming.Reset != 0 {
-		if incomingNewer || existing.Reset == 0 {
-			keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
-			existing.Reset = incoming.Reset
+	if existing.ResetWeekday != 0 || incoming.ResetWeekday != 0 {
+		// A mode switch must carry its zeroes, not fill them from another mode.
+		// Empty snapshots still preserve the existing schedule during migration.
+		incomingSet := incoming.Reset != 0 || incoming.ResetDay != 0 || incoming.ResetWeekday != 0
+		existingSet := existing.Reset != 0 || existing.ResetDay != 0 || existing.ResetWeekday != 0
+		if incomingSet && (incomingNewer || !existingSet) {
+			for _, field := range []struct {
+				name    string
+				current *int
+				value   int
+			}{
+				{"reset", &existing.Reset, incoming.Reset},
+				{"resetDay", &existing.ResetDay, incoming.ResetDay},
+				{"resetWeekday", &existing.ResetWeekday, incoming.ResetWeekday},
+			} {
+				if *field.current != field.value {
+					keep(field.name, *field.current, field.value, field.value)
+					*field.current = field.value
+				}
+			}
 		}
-	}
-	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
+	} else {
+		if existing.Reset != incoming.Reset && incoming.Reset != 0 {
+			if incomingNewer || existing.Reset == 0 {
+				keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
+				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 {

+ 2 - 6
internal/sub/build_urls_test.go

@@ -6,17 +6,13 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func initSubDB(t *testing.T) {
 	t.Helper()
-	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	// Close the handle before t.TempDir cleanup so Windows doesn't refuse to
-	// remove the still-open sqlite file.
-	t.Cleanup(func() { _ = database.CloseDB() })
+	dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
 }
 
 // The subscription page's Copy URL must be built from the same host the

+ 6 - 0
internal/sub/clash_external.go

@@ -55,6 +55,12 @@ func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[strin
 		if flow, _ := settings["flow"].(string); flow != "" {
 			proxy["flow"] = flow
 		}
+		if encryption, ok := settings["encryption"].(string); ok {
+			encryption = strings.TrimSpace(encryption)
+			if encryption != "" && encryption != "none" {
+				proxy["encryption"] = encryption
+			}
+		}
 	case "trojan":
 		server := firstServer(settings)
 		if server == nil {

+ 124 - 0
internal/sub/clash_external_vless_test.go

@@ -0,0 +1,124 @@
+package sub
+
+import (
+	"encoding/base64"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/goccy/go-yaml"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+const clashExternalVlessLink = "vless://[email protected]:443?type=tcp&security=reality&sni=example.com&pbk=test-public-key&sid=ab12&fp=chrome&flow=xtls-rprx-vision"
+
+func TestClashExternalVlessEncryption(t *testing.T) {
+	svc := NewSubClashService(false, "", &SubService{})
+	for _, tc := range []struct {
+		name  string
+		query string
+		want  string
+	}{
+		{"encrypted", "&encryption=" + url.QueryEscape(testMlkemEncryption), testMlkemEncryption},
+		{"trimmed", "&encryption=" + url.QueryEscape(" \t"+testMlkemEncryption+" \n"), testMlkemEncryption},
+		{"none", "&encryption=none", ""},
+		{"trimmed none", "&encryption=%20none%20", ""},
+		{"empty", "&encryption=", ""},
+		{"whitespace", "&encryption=%20%09", ""},
+		{"missing", "", ""},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			proxy := svc.clashProxyFromExternal(clashExternalVlessLink+tc.query+"#external", "external")
+			if proxy == nil {
+				t.Fatal("expected a VLESS proxy")
+			}
+			if got, exists := proxy["encryption"]; tc.want == "" {
+				if exists {
+					t.Errorf("plain VLESS encryption should be omitted, got %v", got)
+				}
+			} else if got != tc.want {
+				t.Errorf("encryption = %v, want %q", got, tc.want)
+			}
+			for key, want := range map[string]any{
+				"type": "vless", "uuid": "22222222-2222-4222-8222-222222222222",
+				"server": "198.51.100.9", "port": 443, "network": "tcp",
+				"flow": "xtls-rprx-vision", "tls": true, "servername": "example.com",
+			} {
+				if got := proxy[key]; got != want {
+					t.Errorf("%s = %v, want %v", key, got, want)
+				}
+			}
+			if _, exists := proxy["packet-encoding"]; exists {
+				t.Error("VLESS encryption must not be exported as packet-encoding")
+			}
+		})
+	}
+}
+
+// Regression for #6572: both pasted links and fetched subscriptions must keep
+// encryption when their nodes are merged with the client's local inbound.
+func TestClashMergedExternalVlessEncryption(t *testing.T) {
+	link := clashExternalVlessLink + "&encryption=" + url.QueryEscape(testMlkemEncryption) + "#external"
+	for _, source := range []string{"link", "subscription-plain", "subscription-base64"} {
+		t.Run(source, func(t *testing.T) {
+			seedSubDB(t)
+			resetSubscriptionCache(t)
+			seedSubInbound(t, "merged-vless", "local", 10001, 1, wsTLSStream)
+			db := database.GetDB()
+			var client model.ClientRecord
+			if err := db.Where("email = ?", "local@e").First(&client).Error; err != nil {
+				t.Fatal(err)
+			}
+			entry := model.ClientExternalLink{ClientId: client.Id, Kind: model.ExternalLinkKindLink, Value: link}
+			if source != "link" {
+				body := link + "\n"
+				if source == "subscription-base64" {
+					body = base64.StdEncoding.EncodeToString([]byte(body))
+				}
+				srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					_, _ = w.Write([]byte(body))
+				}))
+				defer srv.Close()
+				previousClient := subscriptionHTTPClient
+				subscriptionHTTPClient = srv.Client()
+				t.Cleanup(func() { subscriptionHTTPClient = previousClient })
+				entry.Kind = model.ExternalLinkKindSubscription
+				entry.Value = srv.URL
+			}
+			if err := db.Create(&entry).Error; err != nil {
+				t.Fatal(err)
+			}
+
+			w := fetchClashSub(t, clashSubRouter(t), "/clash/merged-vless?view=raw")
+			if w.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
+			}
+			var config struct {
+				Proxies []struct {
+					UUID       string `yaml:"uuid"`
+					Encryption string `yaml:"encryption"`
+				} `yaml:"proxies"`
+			}
+			if err := yaml.Unmarshal(w.Body.Bytes(), &config); err != nil {
+				t.Fatalf("decode Clash YAML: %v", err)
+			}
+			if len(config.Proxies) != 2 {
+				t.Fatalf("expected local and external proxies, got %#v", config.Proxies)
+			}
+			want := map[string]string{client.UUID: "", "22222222-2222-4222-8222-222222222222": testMlkemEncryption}
+			for _, proxy := range config.Proxies {
+				encryption, ok := want[proxy.UUID]
+				if !ok || proxy.Encryption != encryption {
+					t.Errorf("unexpected proxy: %#v", proxy)
+				}
+				delete(want, proxy.UUID)
+			}
+			if len(want) != 0 {
+				t.Errorf("missing proxies: %v", want)
+			}
+		})
+	}
+}

+ 46 - 0
internal/sub/clash_service.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"maps"
@@ -521,11 +522,17 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 			if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
 				proxy["client-fingerprint"] = fp
 			}
+			if certFingerprint := mihomoCertFingerprint(inner["pinnedPeerCertSha256"]); certFingerprint != "" {
+				proxy["fingerprint"] = certFingerprint
+			}
 		}
 	}
 	if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
 		proxy["skip-cert-verify"] = true
 	}
+	if certFingerprint := mihomoCertFingerprint(ep["pinnedPeerCertSha256"]); certFingerprint != "" {
+		proxy["fingerprint"] = certFingerprint
+	}
 
 	// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
 	// block the subscription link generator uses.
@@ -555,6 +562,45 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 	return proxy
 }
 
+// Mihomo supports only one certificate fingerprint, so mihomoCertFingerprint
+// converts the first valid SHA-256 pin to its colon-separated TLS form.
+func mihomoCertFingerprint(value any) string {
+	var pins []string
+	switch typed := value.(type) {
+	case []any:
+		for _, item := range typed {
+			if pin, ok := item.(string); ok {
+				pins = append(pins, pin)
+			}
+		}
+	case []string:
+		pins = typed
+	case string:
+		pins = strings.Split(typed, ",")
+	}
+
+	for _, pin := range pins {
+		normalized := hysteriaPinHex(pin)
+		if len(normalized) != 64 {
+			continue
+		}
+		if _, err := hex.DecodeString(normalized); err != nil {
+			continue
+		}
+		normalized = strings.ToUpper(normalized)
+		var out strings.Builder
+		out.Grow(95)
+		for i := 0; i < len(normalized); i += 2 {
+			if i > 0 {
+				out.WriteByte(':')
+			}
+			out.WriteString(normalized[i : i+2])
+		}
+		return out.String()
+	}
+	return ""
+}
+
 // buildWireguardProxy produces a mihomo-compatible Clash entry for a native
 // WireGuard inbound, mirroring genWireguardLink: the peer public key is derived
 // from the inbound secretKey, while the private key, tunnel address, and

+ 74 - 0
internal/sub/clash_service_test.go

@@ -1431,3 +1431,77 @@ func TestBuildAmneziaWGProxyForClashEffectiveMTU(t *testing.T) {
 		}
 	})
 }
+
+func TestBuildHysteriaProxyIncludesCertificateFingerprint(t *testing.T) {
+	const pin = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+	const want = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
+	const externalPin = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
+	const wantExternal = "FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00"
+
+	svc := &SubClashService{}
+	subReq := &SubService{}
+	inbound := &model.Inbound{
+		Protocol: model.Hysteria,
+		Listen:   "192.0.2.1",
+		Port:     443,
+		Remark:   "hysteria 2",
+		Settings: `{"version":2}`,
+		StreamSettings: `{
+			"tlsSettings": {
+				"alpn": ["h3"],
+				"settings": {
+					"fingerprint": "chrome",
+					"pinnedPeerCertSha256": ["` + pin + `"]
+				}
+			}
+		}`,
+	}
+	client := model.Client{Email: "client", Auth: "secret", Enable: true}
+
+	proxy := svc.buildHysteriaProxy(subReq, inbound, client, nil)
+	if got := proxy["fingerprint"]; got != want {
+		t.Fatalf("fingerprint = %v, want %s", got, want)
+	}
+	if got := proxy["client-fingerprint"]; got != "chrome" {
+		t.Fatalf("client-fingerprint = %v, want chrome", got)
+	}
+
+	externalProxy := svc.buildHysteriaProxy(subReq, inbound, client, map[string]any{
+		"pinnedPeerCertSha256": []any{externalPin},
+	})
+	if got := externalProxy["fingerprint"]; got != wantExternal {
+		t.Fatalf("external fingerprint = %v, want %s", got, wantExternal)
+	}
+}
+
+func TestMihomoCertFingerprintUsesFirstValidPin(t *testing.T) {
+	const firstPin = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+	const secondPin = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
+	const wantFirst = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
+	const wantSecond = "FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00"
+
+	tests := []struct {
+		name string
+		pins any
+		want string
+	}{
+		{
+			name: "invalid first pin uses second",
+			pins: []any{"not-a-certificate-pin", secondPin},
+			want: wantSecond,
+		},
+		{
+			name: "two valid pins use first",
+			pins: []any{firstPin, secondPin},
+			want: wantFirst,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := mihomoCertFingerprint(tt.pins); got != tt.want {
+				t.Fatalf("mihomoCertFingerprint() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}

+ 8 - 0
internal/sub/controller.go

@@ -62,6 +62,7 @@ type SUBController struct {
 	subJsonRoutingRules string
 	subHideSettings     bool
 	happConfig          HappConfig
+	incyConfig          IncyConfig
 
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
@@ -124,6 +125,7 @@ type subControllerConfig struct {
 	subRoutingRules  string
 	subHideSettings  bool
 	happConfig       HappConfig
+	incyConfig       IncyConfig
 
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
@@ -259,6 +261,10 @@ func WithSUBHappConfig(value HappConfig) SUBControllerOption {
 	return func(config *subControllerConfig) { config.happConfig = value }
 }
 
+func WithSUBIncyConfig(value IncyConfig) SUBControllerOption {
+	return func(config *subControllerConfig) { config.incyConfig = value }
+}
+
 func defaultSUBControllerConfig() subControllerConfig {
 	return subControllerConfig{
 		subPath:        "/sub/",
@@ -293,6 +299,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 		subJsonRoutingRules: config.subJsonRoutingRules,
 		subHideSettings:     config.subHideSettings,
 		happConfig:          config.happConfig,
+		incyConfig:          config.incyConfig,
 
 		subIncyEnableRouting: config.subIncyEnableRouting,
 		subIncyRoutingRules:  config.subIncyRoutingRules,
@@ -974,4 +981,5 @@ func (a *SUBController) ApplyCommonHeaders(
 	}
 
 	ApplyHappHeaders(c, a.happConfig, happManaged)
+	ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && c.Request != nil && IsIncyClient(c.GetHeader("User-Agent")))
 }

+ 1 - 2
internal/sub/default.json

@@ -49,7 +49,6 @@
       "tag": "direct",
       "protocol": "freedom",
       "settings": {
-        "domainStrategy": "AsIs",
         "redirect": "",
         "noises": []
       }
@@ -89,4 +88,4 @@
     ]
   },
   "stats": {}
-}
+}

+ 39 - 0
internal/sub/default_json_test.go

@@ -0,0 +1,39 @@
+package sub
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+// xray-core moves freedom settings.domainStrategy to sockopt with a warning on
+// every load (#6482); the embed omits it and gets the AsIs default.
+func TestDefaultJSON_FreedomOutboundHasNoLegacyDomainStrategy(t *testing.T) {
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(defaultJson), &cfg); err != nil {
+		t.Fatalf("unmarshal embedded default.json: %v", err)
+	}
+	outbounds, _ := cfg["outbounds"].([]any)
+	var sawFreedom bool
+	for _, raw := range outbounds {
+		ob, _ := raw.(map[string]any)
+		proto, _ := ob["protocol"].(string)
+		if !strings.EqualFold(proto, "freedom") {
+			continue
+		}
+		sawFreedom = true
+		settings, _ := ob["settings"].(map[string]any)
+		if _, ok := settings["domainStrategy"]; ok {
+			t.Fatalf("freedom outbound %q still has settings.domainStrategy=%v; use sockopt or omit (AsIs default)", ob["tag"], settings["domainStrategy"])
+		}
+		if _, ok := settings["targetStrategy"]; ok {
+			t.Fatalf("freedom outbound %q still has settings.targetStrategy", ob["tag"])
+		}
+		if _, ok := ob["targetStrategy"]; ok {
+			t.Fatalf("freedom outbound %q still has root targetStrategy", ob["tag"])
+		}
+	}
+	if !sawFreedom {
+		t.Fatal("embedded default.json has no freedom outbound to check")
+	}
+}

+ 1 - 1
internal/sub/external_config.go

@@ -163,7 +163,7 @@ func applyRemarkToLink(rawLink, remark string) string {
 	if i := strings.IndexByte(rawLink, '#'); i >= 0 {
 		rawLink = rawLink[:i]
 	}
-	return rawLink + "#" + url.PathEscape(remark)
+	return rawLink + "#" + escapeLinkFragment(remark, url.PathEscape)
 }
 
 func applyVmessRemark(rawLink, remark string) string {

+ 5 - 37
internal/sub/external_hwid_test.go

@@ -6,43 +6,14 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/database"
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 // #6559: the Master panel must send a stable X-HWID when fetching external
 // subscriptions, otherwise an HWID-limited donor answers 404.
-func TestServerHwidStableAcrossCalls(t *testing.T) {
-	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
-
-	first := serverHwid()
-	if first == "" {
-		t.Fatal("serverHwid returned empty")
-	}
-
-	second := serverHwid()
-	if second != first {
-		t.Fatalf("hwid not stable: %q vs %q", first, second)
-	}
-
-	var row model.Setting
-	if err := database.GetDB().Where("key = ?", serverHwidKey).First(&row).Error; err != nil {
-		t.Fatalf("hwid not persisted: %v", err)
-	}
-	if row.Value != first {
-		t.Fatalf("persisted hwid %q != returned %q", row.Value, first)
-	}
-}
-
-// The fetch must carry the stable id so an HWID-limited donor lets it through.
 func TestFetchSendsStableHwid(t *testing.T) {
-	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
 
 	var gotHwid string
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -58,10 +29,7 @@ func TestFetchSendsStableHwid(t *testing.T) {
 	if len(res.links) != 1 {
 		t.Fatalf("links = %v", res.links)
 	}
-	if gotHwid == "" {
-		t.Fatal("X-HWID header missing on fetch")
-	}
-	if gotHwid != serverHwid() {
-		t.Fatalf("sent %q != stable %q", gotHwid, serverHwid())
+	if want := service.ExternalSubscriptionHwid(); gotHwid == "" || gotHwid != want {
+		t.Fatalf("X-HWID = %q, want the panel's stable %q", gotHwid, want)
 	}
 }

+ 40 - 0
internal/sub/external_remark_test.go

@@ -0,0 +1,40 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+)
+
+// #6575: a trailing ?serverDescription=<base64> must stay literal in the
+// fragment so Happ renders its subtitle; only the display name is escaped.
+func TestApplyRemarkKeepsServerDescription(t *testing.T) {
+	link := "vless://[email protected]:443?type=tcp&security=reality&pbk=XXX&fp=chrome&sni=example.org&sid=00&flow=xtls-rprx-vision&encryption=none"
+	remark := "🇵🇱 Warsaw ⚡️?serverDescription=0JTQu9GPIExURSAo0LHQtdC70YvQtSDRgdC/0LjRgdC60Lgp"
+
+	out := applyRemarkToLink(link, remark)
+	frag := out[strings.IndexByte(out, '#')+1:]
+	if !strings.Contains(frag, "?serverDescription=") {
+		t.Fatalf("serverDescription escaped: %s", out)
+	}
+	if strings.Contains(frag, "%3F") || strings.Contains(frag, "%2F") {
+		t.Fatalf("fragment over-escaped: %s", out)
+	}
+	tail := frag[strings.Index(frag, "?serverDescription=")+len("?serverDescription="):]
+	if strings.ContainsAny(tail, " \r\n\t#&") {
+		t.Fatalf("tail not clean base64: %q", tail)
+	}
+	if !strings.HasPrefix(out, link+"#") {
+		t.Fatalf("link body altered: %s", out)
+	}
+}
+
+func TestApplyRemarkMalformedServerDescriptionFallsBack(t *testing.T) {
+	link := "vless://[email protected]:443?security=reality#old"
+	out := applyRemarkToLink(link, "name?serverDescription=not base64!!")
+	if strings.Contains(out, "?serverDescription=") {
+		t.Fatalf("malformed tail kept literal: %s", out)
+	}
+	if !strings.HasPrefix(out, link[:strings.IndexByte(link, '#')]+"#") {
+		t.Fatalf("link body altered: %s", out)
+	}
+}

+ 12 - 35
internal/sub/external_subscription.go

@@ -9,11 +9,10 @@ import (
 	"sync"
 	"time"
 
-	"github.com/google/uuid"
-
 	"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/web/service"
 )
 
 // External subscription fetching: a remote URL whose body is a share-link
@@ -149,9 +148,9 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
 		return nil, err
 	}
 	// Some providers gate the link body on a known client User-Agent.
-	req.Header.Set("User-Agent", "v2rayNG/1.8.5")
+	req.Header.Set("User-Agent", externalSubUserAgent())
 	// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
-	if hwid := serverHwid(); hwid != "" {
+	if hwid := service.ExternalSubscriptionHwid(); hwid != "" {
 		req.Header.Set("X-HWID", hwid)
 	}
 	resp, err := subscriptionHTTPClient.Do(req)
@@ -177,39 +176,17 @@ var (
 	errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
 )
 
-// serverHwidKey is the settings row holding this panel's stable identity
-// for outbound external-subscription fetches.
-const serverHwidKey = "externalSubHwid"
-
-// serverHwidMu serializes first-time creation: without it, concurrent first
-// fetches of different URLs each mint and persist their own UUID.
-var serverHwidMu sync.Mutex
-
-// serverHwid returns a stable per-installation id, creating and persisting
-// it on first use. Empty means the DB is unreachable: send no header then.
-func serverHwid() string {
-	serverHwidMu.Lock()
-	defer serverHwidMu.Unlock()
-	db := database.GetDB()
-	if db == nil {
-		return ""
+// externalSubUserAgent returns the panel setting for external subscription
+// fetches, or the historical client UA when it is unset or the DB is unreachable.
+func externalSubUserAgent() string {
+	if database.GetDB() == nil {
+		return service.DefaultExternalSubUserAgent
 	}
-	var row model.Setting
-	if err := db.Where("key = ?", serverHwidKey).First(&row).Error; err == nil {
-		if strings.TrimSpace(row.Value) != "" {
-			return strings.TrimSpace(row.Value)
-		}
-	}
-	hwid := "3x-ui-server-" + uuid.NewString()
-	row = model.Setting{Key: serverHwidKey, Value: hwid}
-	if err := db.Where(model.Setting{Key: serverHwidKey}).FirstOrCreate(&row).Error; err != nil {
-		logger.Warningf("sub: persisting server hwid failed: %v", err)
-		return ""
-	}
-	if strings.TrimSpace(row.Value) == "" {
-		return hwid
+	ua, err := (&service.SettingService{}).GetExternalSubUserAgent()
+	if err != nil {
+		return service.DefaultExternalSubUserAgent
 	}
-	return strings.TrimSpace(row.Value)
+	return ua
 }
 
 type subError struct{ msg string }

+ 50 - 0
internal/sub/external_subscription_test.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"net/http"
 	"net/http/httptest"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
@@ -12,6 +13,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
@@ -298,3 +300,51 @@ func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
 		t.Fatalf("cache hit wrote fetch status: %#v", after)
 	}
 }
+
+func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
+	resetSubscriptionCache(t)
+	dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua.db"))
+
+	const customUA = "Happ/4.2.1"
+	if err := database.GetDB().Create(&model.Setting{
+		Key:   "externalSubUserAgent",
+		Value: customUA,
+	}).Error; err != nil {
+		t.Fatalf("save setting: %v", err)
+	}
+
+	var gotUA string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotUA = r.Header.Get("User-Agent")
+		_, _ = w.Write([]byte("vless://uuid@host:443?security=none#x"))
+	}))
+	defer srv.Close()
+
+	res := fetchSubscriptionLinks(srv.URL)
+	if res.err != nil {
+		t.Fatalf("fetch: %v", res.err)
+	}
+	if gotUA != customUA {
+		t.Fatalf("User-Agent = %q, want %q", gotUA, customUA)
+	}
+}
+
+func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
+	resetSubscriptionCache(t)
+	dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua-default.db"))
+
+	var gotUA string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotUA = r.Header.Get("User-Agent")
+		_, _ = w.Write([]byte("vless://uuid@host:443?security=none#x"))
+	}))
+	defer srv.Close()
+
+	res := fetchSubscriptionLinks(srv.URL)
+	if res.err != nil {
+		t.Fatalf("fetch: %v", res.err)
+	}
+	if gotUA != "v2rayNG/1.8.5" {
+		t.Fatalf("User-Agent = %q, want default v2rayNG/1.8.5", gotUA)
+	}
+}

+ 23 - 0
internal/sub/forwarded_trust_test.go

@@ -154,6 +154,29 @@ func TestResolveRequest_GatesRealIPFallback(t *testing.T) {
 	}
 }
 
+// nginx often sets X-Real-IP without X-Forwarded-Host; a trusted proxy's X-Real-IP
+// used to put the subscriber's public IP into subscription "server" fields (#6589).
+func TestResolveRequest_IgnoresRealIPEvenWhenTrusted(t *testing.T) {
+	initSubDB(t)
+	// Empty / shipped-default CIDRs → forwardedHeadersTrusted returns true.
+	s := &SubService{}
+
+	c := requestFrom(t, "10.1.2.3:44000", map[string]string{
+		"X-Real-IP": "198.51.100.7",
+	})
+	_, host, hostWithPort, hostHeader := s.ResolveRequest(c)
+
+	if host != "panel.example.com" {
+		t.Errorf("host = %q, want request host (not X-Real-IP)", host)
+	}
+	if hostWithPort != "panel.example.com:2096" {
+		t.Errorf("hostWithPort = %q, want request Host", hostWithPort)
+	}
+	if hostHeader != "panel.example.com" {
+		t.Errorf("hostHeader = %q, want request host (not X-Real-IP)", hostHeader)
+	}
+}
+
 func TestHasForwardedHeaders(t *testing.T) {
 	tests := []struct {
 		name    string

+ 7 - 0
internal/sub/happ.go

@@ -34,6 +34,7 @@ type HappConfig struct {
 	AutoConnectType     string
 	PerAppMode          string
 	PerAppList          string
+	LocalProxyAuth      string
 }
 
 // IsHappClient checks if the client user-agent identifies as Happ.
@@ -129,6 +130,12 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 			c.Writer.Header().Set("Subscription-Autoconnect-Type", autoType)
 		}
 	}
+	// Happ's local SOCKS/HTTP inbounds default to no auth, so any app on the device can use them
+	// to reach the tunnel and learn the server address; these are standard headers (no ProviderID).
+	if mode := strings.TrimSpace(cfg.LocalProxyAuth); mode != "" {
+		c.Writer.Header().Set("Socks-Auth-Mode", mode)
+		c.Writer.Header().Set("Http-Auth-Mode", mode)
+	}
 	if mode := strings.TrimSpace(cfg.PerAppMode); mode != "" && mode != "off" {
 		switch strings.ToLower(mode) {
 		case "include":

+ 34 - 0
internal/sub/happ_test.go

@@ -348,3 +348,37 @@ func TestIsHappClient(t *testing.T) {
 		}
 	}
 }
+
+func TestApplyHappHeaders_LocalProxyAuth(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	cases := []struct {
+		name       string
+		autoDetect bool
+		userAgent  string
+		mode       string
+		want       string
+	}{
+		{"Happ gets auto for both inbounds", true, "Happ/4.4.1 (Android)", "auto", "auto"},
+		{"explicit disable is forwarded", true, "Happ/4.4.1 (Android)", "disable", "disable"},
+		{"empty mode sends nothing", true, "Happ/4.4.1 (Android)", "", ""},
+		{"non-Happ client gets nothing", true, "v2rayNG/1.8.5", "auto", ""},
+		{"AutoDetect off sends nothing", false, "Happ/4.4.1 (Android)", "auto", ""},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			recorder := httptest.NewRecorder()
+			ctx, _ := gin.CreateTestContext(recorder)
+			ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+			ctx.Request.Header.Set("User-Agent", tc.userAgent)
+
+			controller := &SUBController{happConfig: HappConfig{AutoDetect: tc.autoDetect, LocalProxyAuth: tc.mode}}
+			controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
+
+			for _, h := range []string{"Socks-Auth-Mode", "Http-Auth-Mode"} {
+				if got := recorder.Header().Get(h); got != tc.want {
+					t.Fatalf("%s = %q, want %q", h, got, tc.want)
+				}
+			}
+		})
+	}
+}

+ 2 - 4
internal/sub/host_sub_test.go

@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
@@ -15,10 +16,7 @@ func seedSubDB(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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 }
 
 // seedSubInbound creates a VLESS inbound with one client wired into the

+ 2 - 4
internal/sub/hwid_controller_test.go

@@ -12,6 +12,7 @@ import (
 	"github.com/gin-gonic/gin"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
@@ -27,10 +28,7 @@ func initHwidSubRouter(t *testing.T, limit int) (*gin.Engine, string) {
 	}
 
 	t.Setenv("XUI_DB_FOLDER", tmp)
-	if err := database.InitDB(filepath.Join(tmp, "x-ui.db")); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	dbtest.InitDB(t, filepath.Join(tmp, "x-ui.db"))
 
 	const subID = "sub-hwid-route"
 	const email = "[email protected]"

+ 180 - 0
internal/sub/incy.go

@@ -0,0 +1,180 @@
+package sub
+
+import (
+	"encoding/base64"
+	"regexp"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
+// INCY clients identify themselves as INCY/<version>/<platform>.
+var incyUserAgentRegex = regexp.MustCompile(`(?i)\bincy\b`)
+
+var (
+	incyRangeRegex = regexp.MustCompile(`^\d+(-\d+)?$`)
+	incyHexRegex   = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
+	// Incy documents fragmentation-packets as tlshello | 1-3 | 1 | all.
+	incyPacketsRangeRegex = regexp.MustCompile(`^\d+-\d+$`)
+)
+
+// IncyConfig holds the Incy app-management headers the panel can push.
+// A "" field omits its header, so an untouched panel never overrides the app.
+type IncyConfig struct {
+	AutoDetect bool
+
+	ProfileDescription string
+	SortOrder          string
+	SupportEmail       string
+	AnnounceUrl        string
+	PremiumUrl         string
+
+	BannerText        string
+	BannerButtonText  string
+	BannerButtonUrl   string
+	BannerBgColor     string
+	BannerButtonColor string
+
+	HideUrl        string
+	HideCheck      string
+	NoLimitEnabled string
+
+	PerAppProxyEnable string
+	PerAppProxyMode   string
+	PerAppProxyList   string
+
+	FragmentationEnable   string
+	FragmentationLength   string
+	FragmentationInterval string
+	FragmentationPackets  string
+
+	NoisesEnable string
+	NoisesType   string
+	NoisesPacket string
+	NoisesDelay  string
+
+	ServerAddressResolveEnable    string
+	ServerAddressResolveDnsDomain string
+	ServerAddressResolveDnsIp     string
+}
+
+// IsIncyClient checks if the client user-agent identifies as INCY.
+func IsIncyClient(userAgent string) bool {
+	return incyUserAgentRegex.MatchString(userAgent)
+}
+
+// incyOnOff maps a switch setting to the documented `1`/`0` literal. An
+// unrecognised or unset value stays empty so the header is omitted.
+func incyOnOff(v string) string {
+	switch strings.ToLower(strings.TrimSpace(v)) {
+	case "1", "true", "yes", "on":
+		return "1"
+	case "0", "false", "no", "off":
+		return "0"
+	}
+	return ""
+}
+
+// incyEnum passes through only a documented literal from a fixed value set.
+func incyEnum(v string, allowed ...string) string {
+	value := strings.ToLower(strings.TrimSpace(v))
+	for _, a := range allowed {
+		if value == a {
+			return value
+		}
+	}
+	return ""
+}
+
+// incyMatch returns the trimmed value only when it matches a documented shape.
+func incyMatch(v string, re *regexp.Regexp) string {
+	value := strings.TrimSpace(v)
+	if re.MatchString(value) {
+		return value
+	}
+	return ""
+}
+
+func incyASCII(v string) bool {
+	for i := 0; i < len(v); i++ {
+		if v[i] > 0x7e || v[i] < 0x20 {
+			return false
+		}
+	}
+	return true
+}
+
+// incyPackageList joins a comma- or line-separated app list as CSV, since a
+// header cannot carry the newlines the settings textarea accepts.
+func incyPackageList(v string) string {
+	entries := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' })
+	kept := entries[:0]
+	for _, entry := range entries {
+		if entry = strings.TrimSpace(entry); entry != "" {
+			kept = append(kept, entry)
+		}
+	}
+	return strings.Join(kept, ",")
+}
+
+// incyHeaderText base64-wraps non-ASCII text because the docs require
+// `base64:<...>` for anything outside the ASCII range (Cyrillic, CJK, emoji).
+func incyHeaderText(v string) string {
+	text := sanitizeHeaderValue(v)
+	if text == "" || incyASCII(text) {
+		return text
+	}
+	return "base64:" + base64.StdEncoding.EncodeToString([]byte(text))
+}
+
+// ApplyIncyHeaders sets the Incy app-management headers from
+// https://docs.incy.cc/en/app-management/ (matched case-insensitively).
+func ApplyIncyHeaders(c *gin.Context, cfg IncyConfig, isIncy bool) {
+	if c == nil || c.Writer == nil || !cfg.AutoDetect || !isIncy {
+		return
+	}
+	h := c.Writer.Header()
+	set := func(name, value string) {
+		if value != "" {
+			h.Set(name, value)
+		}
+	}
+
+	set("Profile-Description", incyHeaderText(cfg.ProfileDescription))
+	set("Sort-Order", incyEnum(cfg.SortOrder, "none", "ping", "name"))
+	set("Support-Email", sanitizeHeaderValue(cfg.SupportEmail))
+	set("Announce-Url", sanitizeHeaderValue(cfg.AnnounceUrl))
+	set("Premium-Url", sanitizeHeaderValue(cfg.PremiumUrl))
+
+	set("Banner-Text", incyHeaderText(cfg.BannerText))
+	set("Banner-Button-Text", incyHeaderText(cfg.BannerButtonText))
+	set("Banner-Button-Url", sanitizeHeaderValue(cfg.BannerButtonUrl))
+	set("Banner-Bg-Color", incyMatch(cfg.BannerBgColor, incyHexRegex))
+	set("Banner-Button-Color", incyMatch(cfg.BannerButtonColor, incyHexRegex))
+
+	set("Hide-Url", incyOnOff(cfg.HideUrl))
+	set("Hide-Check", incyOnOff(cfg.HideCheck))
+	set("No-Limit-Enabled", incyOnOff(cfg.NoLimitEnabled))
+
+	set("Per-App-Proxy-Enable", incyOnOff(cfg.PerAppProxyEnable))
+	set("Per-App-Proxy-Mode", incyEnum(cfg.PerAppProxyMode, "bypass", "proxy"))
+	set("Per-App-Proxy-List", incyPackageList(cfg.PerAppProxyList))
+
+	set("Fragmentation-Enable", incyOnOff(cfg.FragmentationEnable))
+	set("Fragmentation-Length", incyMatch(cfg.FragmentationLength, incyRangeRegex))
+	set("Fragmentation-Interval", incyMatch(cfg.FragmentationInterval, incyRangeRegex))
+	if packets := strings.ToLower(strings.TrimSpace(cfg.FragmentationPackets)); packets != "" {
+		if packets == "tlshello" || packets == "all" || packets == "1" || incyPacketsRangeRegex.MatchString(packets) {
+			set("Fragmentation-Packets", packets)
+		}
+	}
+
+	set("Noises-Enable", incyOnOff(cfg.NoisesEnable))
+	set("Noises-Type", incyEnum(cfg.NoisesType, "rand", "str", "hex"))
+	set("Noises-Packet", sanitizeHeaderValue(cfg.NoisesPacket))
+	set("Noises-Delay", incyMatch(cfg.NoisesDelay, incyRangeRegex))
+
+	set("Server-Address-Resolve-Enable", incyOnOff(cfg.ServerAddressResolveEnable))
+	set("Server-Address-Resolve-Dns-Domain", sanitizeHeaderValue(cfg.ServerAddressResolveDnsDomain))
+	set("Server-Address-Resolve-Dns-Ip", sanitizeHeaderValue(cfg.ServerAddressResolveDnsIp))
+}

+ 208 - 0
internal/sub/incy_test.go

@@ -0,0 +1,208 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+func fullIncyConfig() IncyConfig {
+	return IncyConfig{
+		AutoDetect:                    true,
+		ProfileDescription:            "Fast and stable network",
+		SortOrder:                     "ping",
+		SupportEmail:                  "[email protected]",
+		AnnounceUrl:                   "https://t.me/incy_news",
+		PremiumUrl:                    "https://example.com/buy",
+		BannerText:                    "Summer sale",
+		BannerButtonText:              "Buy now",
+		BannerButtonUrl:               "https://example.com/sale",
+		BannerBgColor:                 "#E53E3E",
+		BannerButtonColor:             "#38A169",
+		HideUrl:                       "1",
+		HideCheck:                     "true",
+		NoLimitEnabled:                "0",
+		PerAppProxyEnable:             "1",
+		PerAppProxyMode:               "bypass",
+		PerAppProxyList:               "com.google.chrome,org.telegram.messenger",
+		FragmentationEnable:           "1",
+		FragmentationLength:           "10-30",
+		FragmentationInterval:         "20-40",
+		FragmentationPackets:          "tlshello",
+		NoisesEnable:                  "1",
+		NoisesType:                    "rand",
+		NoisesPacket:                  "10-20",
+		NoisesDelay:                   "10-50",
+		ServerAddressResolveEnable:    "1",
+		ServerAddressResolveDnsDomain: "https://common.dot.dns.yandex.net/dns-query",
+		ServerAddressResolveDnsIp:     "77.88.8.8",
+	}
+}
+
+func applyIncyToHeaders(t *testing.T, cfg IncyConfig, userAgent string) http.Header {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+	if userAgent != "" {
+		ctx.Request.Header.Set("User-Agent", userAgent)
+	}
+	ApplyIncyHeaders(ctx, cfg, cfg.AutoDetect && IsIncyClient(ctx.GetHeader("User-Agent")))
+	return recorder.Header()
+}
+
+func TestIsIncyClient(t *testing.T) {
+	for _, tc := range []struct {
+		userAgent string
+		want      bool
+	}{
+		{"INCY/1.0.0/ios", true},
+		{"incy/2.4/android", true},
+		{"Mozilla/5.0 (Linux; Android 14) INCY/1.2.3/android", true},
+		{"Happ/1.2.0 (iPhone; iOS 17.5)", false},
+		{"v2rayNG/1.8.5", false},
+		{"", false},
+	} {
+		if got := IsIncyClient(tc.userAgent); got != tc.want {
+			t.Errorf("IsIncyClient(%q) = %v, want %v", tc.userAgent, got, tc.want)
+		}
+	}
+}
+
+func TestApplyIncyHeaders_AllDocumentedHeaders(t *testing.T) {
+	cfg := fullIncyConfig()
+	h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
+
+	want := map[string]string{
+		"Profile-Description":               "Fast and stable network",
+		"Sort-Order":                        "ping",
+		"Support-Email":                     "[email protected]",
+		"Announce-Url":                      "https://t.me/incy_news",
+		"Premium-Url":                       "https://example.com/buy",
+		"Banner-Text":                       "Summer sale",
+		"Banner-Button-Text":                "Buy now",
+		"Banner-Button-Url":                 "https://example.com/sale",
+		"Banner-Bg-Color":                   "#E53E3E",
+		"Banner-Button-Color":               "#38A169",
+		"Hide-Url":                          "1",
+		"Hide-Check":                        "1",
+		"No-Limit-Enabled":                  "0",
+		"Per-App-Proxy-Enable":              "1",
+		"Per-App-Proxy-Mode":                "bypass",
+		"Per-App-Proxy-List":                "com.google.chrome,org.telegram.messenger",
+		"Fragmentation-Enable":              "1",
+		"Fragmentation-Length":              "10-30",
+		"Fragmentation-Interval":            "20-40",
+		"Fragmentation-Packets":             "tlshello",
+		"Noises-Enable":                     "1",
+		"Noises-Type":                       "rand",
+		"Noises-Packet":                     "10-20",
+		"Noises-Delay":                      "10-50",
+		"Server-Address-Resolve-Enable":     "1",
+		"Server-Address-Resolve-Dns-Domain": "https://common.dot.dns.yandex.net/dns-query",
+		"Server-Address-Resolve-Dns-Ip":     "77.88.8.8",
+	}
+	for name, value := range want {
+		if got := h.Get(name); got != value {
+			t.Errorf("header %s = %q, want %q", name, got, value)
+		}
+	}
+}
+
+func TestApplyIncyHeaders_SkipsUnsetValues(t *testing.T) {
+	cfg := IncyConfig{AutoDetect: true}
+	cfg.HideUrl = ""
+	cfg.SortOrder = ""
+	h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/ios")
+
+	for _, name := range []string{"Hide-Url", "Sort-Order", "Fragmentation-Enable", "Banner-Text"} {
+		if got := h.Get(name); got != "" {
+			t.Errorf("unset header %s = %q, want omitted", name, got)
+		}
+	}
+}
+
+func TestApplyIncyHeaders_NotAppliedWithoutIncyClient(t *testing.T) {
+	cfg := fullIncyConfig()
+
+	for _, userAgent := range []string{"Happ/1.2.0 (iPhone)", "v2rayNG/1.8.5", ""} {
+		h := applyIncyToHeaders(t, cfg, userAgent)
+		if got := h.Get("Hide-Url"); got != "" {
+			t.Errorf("user-agent %q: Hide-Url = %q, want omitted", userAgent, got)
+		}
+	}
+}
+
+func TestApplyIncyHeaders_NotAppliedWhenAutoDetectOff(t *testing.T) {
+	cfg := fullIncyConfig()
+	cfg.AutoDetect = false
+
+	h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
+	if got := h.Get("Hide-Url"); got != "" {
+		t.Errorf("AutoDetect off: Hide-Url = %q, want omitted", got)
+	}
+}
+
+func TestIncyHeaderText_Base64ForNonASCII(t *testing.T) {
+	ascii := incyHeaderText("Plain banner")
+	if ascii != "Plain banner" {
+		t.Errorf("ASCII text = %q, want unchanged", ascii)
+	}
+
+	cyrillic := incyHeaderText("Здравствуйте")
+	if !strings.HasPrefix(cyrillic, "base64:") {
+		t.Fatalf("Cyrillic text = %q, want base64: prefix", cyrillic)
+	}
+	// The docs require base64 for anything outside the ASCII range, so the
+	// raw value must not survive on the wire.
+	if strings.Contains(cyrillic, "Здравствуйте") {
+		t.Errorf("Cyrillic text = %q, want the raw value base64-encoded", cyrillic)
+	}
+}
+
+func TestApplyIncyHeaders_RejectsUndocumentedValues(t *testing.T) {
+	cfg := IncyConfig{
+		AutoDetect:           true,
+		SortOrder:            "alphabetical", // not none|ping|name
+		PerAppProxyMode:      "include",      // Happ spelling, not bypass|proxy
+		NoisesType:           "uuid",         // not rand|str|hex
+		FragmentationLength:  "10..30",       // not min-max
+		FragmentationPackets: "3",            // documented only as tlshello|1-3|1|all
+		BannerBgColor:        "red",          // not #RRGGBB
+		HideUrl:              "maybe",        // not 1|0
+	}
+	h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
+
+	for _, name := range []string{
+		"Sort-Order", "Per-App-Proxy-Mode", "Noises-Type",
+		"Fragmentation-Length", "Fragmentation-Packets", "Banner-Bg-Color", "Hide-Url",
+	} {
+		if got := h.Get(name); got != "" {
+			t.Errorf("undocumented value accepted for %s: %q", name, got)
+		}
+	}
+}
+
+func TestApplyIncyHeaders_PerAppListKeepsSeparators(t *testing.T) {
+	// The settings textarea takes one package per line, and a header cannot
+	// carry a newline, so each line must stay a separate list entry.
+	for _, tc := range []struct {
+		name, list, want string
+	}{
+		{"newline", "com.google.chrome\norg.telegram.messenger", "com.google.chrome,org.telegram.messenger"},
+		{"crlf and blank lines", "com.google.chrome\r\n\r\norg.telegram.messenger\r\n", "com.google.chrome,org.telegram.messenger"},
+		{"url", " https://example.com/apps.txt ", "https://example.com/apps.txt"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			cfg := IncyConfig{AutoDetect: true, PerAppProxyList: tc.list}
+			h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
+			if got := h.Get("Per-App-Proxy-List"); got != tc.want {
+				t.Errorf("Per-App-Proxy-List = %q, want %q", got, tc.want)
+			}
+		})
+	}
+}

+ 2 - 4
internal/sub/mutation_audit_test.go

@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
@@ -19,10 +20,7 @@ func initMutDB(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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 }
 
 // --- json_service.go:40 — rules are merged into routing only when non-empty ---

+ 2 - 4
internal/sub/remote_routing_test.go

@@ -17,6 +17,7 @@ import (
 	yaml "github.com/goccy/go-yaml"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 )
 
 func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
@@ -607,10 +608,7 @@ func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) {
 
 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() })
+	dbtest.InitDB(t, dbPath)
 
 	deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`))
 	if err != nil {

+ 71 - 29
internal/sub/service.go

@@ -300,15 +300,8 @@ func listenIsInternalOnly(listen string) bool {
 	return isLoopbackHost(listen)
 }
 
-// matchingClients returns the inbound's clients whose SubID equals subId,
-// resolved from the normalized clients/client_inbounds tables (both filter
-// columns indexed) instead of parsing the settings JSON — at large client
-// counts that parse made every subscription fetch cost seconds. The
-// case-insensitive email dedupe stays as cheap insurance even though
-// clients.email is unique, preserving the #5134 guarantee that duplicate
-// settings entries never fan out into duplicate profiles. Resolved clients
-// are primed into the per-request cache so the link generators don't parse
-// settings either.
+// matchingClients selects normalized subId members (email-deduped, #5134).
+// WG/AWG copy this inbound's settings tunnel identity first so shared wg_* columns cannot leak (#6641).
 func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
 	clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
 	if err != nil {
@@ -325,10 +318,58 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 		seen[key] = struct{}{}
 		out = append(out, client)
 	}
+	if len(out) > 0 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
+		overlaid, settingsErr := s.overlayInboundTunnelIdentity(inbound, out)
+		if settingsErr != nil {
+			logger.Errorf("SubService - matchingClients: inbound %d tunnel settings: %v", inbound.Id, settingsErr)
+			return nil
+		}
+		out = overlaid
+	}
 	s.primeLinkClients(inbound.Id, out, false)
 	return out
 }
 
+// overlayInboundTunnelIdentity copies per-inbound tunnel fields from settings.
+// An unmatched peer is dropped, malformed settings yield nothing, and empty optional secrets replace shared values (#6641).
+func (s *SubService) overlayInboundTunnelIdentity(inbound *model.Inbound, clients []model.Client) ([]model.Client, error) {
+	embedded, err := s.inboundService.GetClients(inbound)
+	if err != nil {
+		return nil, err
+	}
+	byEmail := make(map[string]model.Client, len(embedded))
+	for i := range embedded {
+		key := strings.ToLower(embedded[i].Email)
+		if key == "" {
+			continue
+		}
+		if _, exists := byEmail[key]; exists {
+			continue
+		}
+		byEmail[key] = embedded[i]
+	}
+	out := make([]model.Client, 0, len(clients))
+	for _, client := range clients {
+		peer, ok := byEmail[strings.ToLower(client.Email)]
+		if !ok {
+			continue
+		}
+		client.PrivateKey = peer.PrivateKey
+		client.PublicKey = peer.PublicKey
+		client.PreSharedKey = peer.PreSharedKey
+		// append onto nil copies a non-empty list and clears a shared address when settings omit one.
+		client.AllowedIPs = append([]string(nil), peer.AllowedIPs...)
+		if peer.KeepAlive == nil {
+			client.KeepAlive = nil
+		} else {
+			keepalive := *peer.KeepAlive
+			client.KeepAlive = &keepalive
+		}
+		out = append(out, client)
+	}
+	return out, nil
+}
+
 // RecordSubscriptionFetch records a successful subscription response for all clients sharing subId.
 func (s *SubService) RecordSubscriptionFetch(subId string) error {
 	if strings.TrimSpace(subId) == "" {
@@ -2289,22 +2330,29 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
 
 	if fragment != "" {
 		sb.WriteByte('#')
-		if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok {
-			if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") {
-				sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20"))
-				sb.WriteString("?serverDescription=")
-				sb.WriteString(after)
-			} else {
-				sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
-			}
-		} else {
-			// Match the frontend's encodeURIComponent(remark): spaces become %20.
-			sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
-		}
+		sb.WriteString(escapeLinkFragment(fragment, encodeURIComponent))
 	}
 	return sb.String()
 }
 
+// encodeURIComponent matches the frontend's escaping of a remark: spaces become %20.
+func encodeURIComponent(s string) string {
+	return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
+}
+
+// escapeLinkFragment escapes a remark but keeps a valid ?serverDescription=<base64>
+// tail literal, which Happ reads as the subtitle (#6488, #6575).
+func escapeLinkFragment(fragment string, escape func(string) string) string {
+	before, after, ok := strings.Cut(fragment, "?serverDescription=")
+	if !ok || after == "" || strings.ContainsAny(after, " \r\n\t#&") {
+		return escape(fragment)
+	}
+	if _, err := base64.StdEncoding.DecodeString(after); err != nil {
+		return escape(fragment)
+	}
+	return escape(before) + "?serverDescription=" + after
+}
+
 // buildExternalProxyURLLinks is a thin adapter: it maps the legacy externalProxy
 // entries to []ShareEndpoint and renders them through the unified endpoint path.
 // Kept so the genVless/genTrojan/genShadowsocks call sites are unchanged.
@@ -2951,8 +2999,8 @@ type PageData struct {
 	Emails        []string
 }
 
-// ResolveRequest extracts scheme and host info from request/headers consistently.
 // ResolveRequest extracts scheme, host, and header information from an HTTP request.
+// X-Real-IP names the visitor, never the panel, so it is no host source (#6589).
 func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
 	trusted := s.forwardedHeadersTrusted(c)
 	if !trusted {
@@ -2971,13 +3019,10 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 		scheme = "https"
 	}
 
-	// base host (no port)
+	// base host (no port): trusted X-Forwarded-Host, then the dialed request Host.
 	if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
 		host = h
 	}
-	if host == "" {
-		host = forwarded("X-Real-IP")
-	}
 	if host == "" {
 		var err error
 		host, _, err = net.SplitHostPort(c.Request.Host)
@@ -2997,9 +3042,6 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 
 	// header display host
 	hostHeader = forwarded("X-Forwarded-Host")
-	if hostHeader == "" {
-		hostHeader = forwarded("X-Real-IP")
-	}
 	if hostHeader == "" {
 		hostHeader = host
 	}

+ 320 - 0
internal/sub/service_amneziawg_test.go

@@ -2,6 +2,7 @@ package sub
 
 import (
 	"encoding/base64"
+	"fmt"
 	"slices"
 	"strconv"
 	"strings"
@@ -315,3 +316,322 @@ func TestAmneziaWGConfigTextAlwaysCarriesTheServerMTU(t *testing.T) {
 		})
 	}
 }
+
+func decodeAmneziaWGSubLink(t *testing.T, link string) string {
+	t.Helper()
+	if !strings.HasPrefix(link, "vpn://") {
+		t.Fatalf("link = %q, want vpn:// prefix", link)
+	}
+	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://"))
+	if err != nil {
+		t.Fatalf("decode vpn link: %v\n got: %s", err, link)
+	}
+	return string(raw)
+}
+
+// The shared clients row holds the last sync's tunnel identity. Each vpn://
+// entry must keep its own inbound address and private key, in either sort order (#6641).
+func TestGetSubs_PreservesPerInboundAmneziaWGIdentity(t *testing.T) {
+	serverAPriv, serverAPub := mustWireguardKeypair(t)
+	serverBPriv, serverBPub := mustWireguardKeypair(t)
+	privA, _ := mustWireguardKeypair(t)
+	privB, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@awg"
+		subID      = "sub-awg-identity"
+		mergedAddr = "10.9.9.9/32"
+	)
+	nodes := []struct {
+		tag, listen, addr, priv, serverPriv, serverPub string
+		port                                           int
+	}{
+		{"awg-a", "203.0.113.10", "10.8.1.2/32", privA, serverAPriv, serverAPub, 51820},
+		{"awg-b", "203.0.113.11", "10.8.2.2/32", privB, serverBPriv, serverBPub, 51821},
+	}
+
+	for _, tc := range []struct {
+		name  string
+		sort  [2]int
+		order [2]int
+	}{
+		{name: "creation order", sort: [2]int{1, 2}, order: [2]int{0, 1}},
+		{name: "reversed subscription sort", sort: [2]int{2, 1}, order: [2]int{1, 0}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			initSubDB(t)
+			db := database.GetDB()
+			inbounds := make([]*model.Inbound, len(nodes))
+			for i, n := range nodes {
+				settings := fmt.Sprintf(
+					`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q],"enable":true}]}`,
+					n.serverPriv, n.serverPub, email, n.priv, n.addr,
+				)
+				ib := &model.Inbound{
+					UserId: 1, Tag: n.tag, Enable: true, Listen: n.listen, Port: n.port,
+					Protocol: model.AmneziaWG, Remark: n.tag, Settings: settings, SubSortIndex: tc.sort[i],
+				}
+				if err := db.Create(ib).Error; err != nil {
+					t.Fatalf("create %s: %v", n.tag, err)
+				}
+				inbounds[i] = ib
+			}
+			rec := &model.ClientRecord{
+				Email: email, SubID: subID, Enable: true,
+				PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+				PreSharedKey: "sharedpsk", KeepAlive: 25,
+			}
+			if err := db.Create(rec).Error; err != nil {
+				t.Fatalf("create client: %v", err)
+			}
+			for _, ib := range inbounds {
+				if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+					t.Fatalf("link %s: %v", ib.Tag, err)
+				}
+			}
+
+			links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+			if err != nil {
+				t.Fatalf("GetSubs: %v", err)
+			}
+			if len(links) != len(nodes) {
+				t.Fatalf("links = %d, want %d: %v", len(links), len(nodes), links)
+			}
+			for outIdx, nodeIdx := range tc.order {
+				n := nodes[nodeIdx]
+				other := nodes[1-nodeIdx]
+				conf := decodeAmneziaWGSubLink(t, links[outIdx])
+				for _, want := range []string{
+					"PrivateKey = " + n.priv,
+					"Address = " + n.addr,
+					"PublicKey = " + n.serverPub,
+					fmt.Sprintf("Endpoint = %s:%d", n.listen, n.port),
+				} {
+					if !strings.Contains(conf, want) {
+						t.Fatalf("config missing %q\n%s", want, conf)
+					}
+				}
+				for _, leaked := range []string{mergedPriv, mergedAddr, "sharedpsk", "PresharedKey", "PersistentKeepalive", other.priv, other.addr, other.serverPub} {
+					if strings.Contains(conf, leaked) {
+						t.Fatalf("config leaked %q\n%s", leaked, conf)
+					}
+				}
+			}
+		})
+	}
+}
+
+// A peer missing from settings, or settings that do not parse, must not emit the
+// shared clients.wg_* identity. A sibling inbound with its own peer still does (#6641).
+func TestGetSubs_AmneziaWGUnavailableSettingsEmitNoSharedConfig(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	validPriv, _ := mustWireguardKeypair(t)
+	otherPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@awg"
+		subID      = "sub-awg-missing"
+		validAddr  = "10.8.1.4/32"
+		mergedAddr = "10.9.9.9/32"
+	)
+	validSettings := fmt.Sprintf(
+		`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q],"enable":true}]}`,
+		serverPriv, serverPub, email, validPriv, validAddr,
+	)
+	absentSettings := fmt.Sprintf(
+		`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":"someone-else@awg","privateKey":%q,"allowedIPs":["10.8.9.9/32"],"enable":true}]}`,
+		serverPriv, serverPub, otherPriv,
+	)
+	specs := []struct {
+		tag, listen, settings string
+		port                  int
+	}{
+		{"awg-bad-json", "203.0.113.31", `{not-json`, 51831},
+		{"awg-absent-peer", "203.0.113.32", absentSettings, 51832},
+		{"awg-valid", "203.0.113.33", validSettings, 51833},
+	}
+	inbounds := make([]*model.Inbound, len(specs))
+	for i, sp := range specs {
+		ib := &model.Inbound{
+			UserId: 1, Tag: sp.tag, Enable: true, Listen: sp.listen, Port: sp.port,
+			Protocol: model.AmneziaWG, Remark: sp.tag, Settings: sp.settings, SubSortIndex: i + 1,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create %s: %v", sp.tag, err)
+		}
+		inbounds[i] = ib
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	for _, ib := range inbounds {
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("link %s: %v", ib.Tag, err)
+		}
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1 (absent and malformed inbounds must not emit the shared row): %q", len(links), links)
+	}
+	conf := decodeAmneziaWGSubLink(t, links[0])
+	for _, want := range []string{
+		"PrivateKey = " + validPriv,
+		"Address = " + validAddr,
+		"Endpoint = 203.0.113.33:51833",
+	} {
+		if !strings.Contains(conf, want) {
+			t.Fatalf("config missing %q\n%s", want, conf)
+		}
+	}
+	for _, leaked := range []string{mergedPriv, mergedAddr, "sharedpsk", otherPriv, "10.8.9.9/32", "203.0.113.31", "203.0.113.32", "PresharedKey", "PersistentKeepalive"} {
+		if strings.Contains(conf, leaked) {
+			t.Fatalf("config leaked %q\n%s", leaked, conf)
+		}
+	}
+}
+
+// Explicit empty preshared key and keepalive must not inherit the shared row (#6641).
+func TestGetSubs_AmneziaWGEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	clientPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "optional@awg"
+		subID      = "sub-awg-optional"
+		addr       = "10.8.1.8/32"
+		mergedAddr = "10.9.9.9/32"
+	)
+	settings := fmt.Sprintf(
+		`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q],"preSharedKey":"","keepAlive":0,"enable":true}]}`,
+		serverPriv, serverPub, email, clientPriv, addr,
+	)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "awg-optional", Enable: true, Listen: "203.0.113.40", Port: 51840,
+		Protocol: model.AmneziaWG, Remark: "awg-optional", Settings: settings,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("link client: %v", err)
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %q", len(links), links)
+	}
+	conf := decodeAmneziaWGSubLink(t, links[0])
+	for _, want := range []string{"PrivateKey = " + clientPriv, "Address = " + addr} {
+		if !strings.Contains(conf, want) {
+			t.Fatalf("config missing %q\n%s", want, conf)
+		}
+	}
+	for _, leaked := range []string{"PresharedKey", "PersistentKeepalive", "sharedpsk", mergedPriv, mergedAddr} {
+		if strings.Contains(conf, leaked) {
+			t.Fatalf("config leaked %q\n%s", leaked, conf)
+		}
+	}
+}
+
+// Membership and account metadata stay on the normalized row. Settings may carry a
+// stale subId/enable and an extra email; tunnel fields still come from this inbound (#6641).
+func TestMatchingClients_TunnelMetadataStaysNormalized(t *testing.T) {
+	const (
+		subID       = "sub-meta"
+		email       = "user@awg"
+		freshID     = "11111111-2222-4333-8444-555555555555"
+		staleID     = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
+		settingsKey = "settings-private-key"
+		sharedKey   = "shared-private-key"
+		expiry      = int64(1700000000000)
+	)
+	clientsJSON := fmt.Sprintf(`[
+		{"id":%q,"email":"User@AWG","subId":"stale-sub","enable":false,"totalGB":1,"expiryTime":1,"comment":"stale","limitIp":9,"privateKey":%q,"publicKey":"settings-pub","allowedIPs":["10.8.1.2/32","fd00::2/128"],"preSharedKey":"settings-psk","keepAlive":15},
+		{"email":"settings-only@awg","subId":%q,"enable":true,"privateKey":"only-priv","allowedIPs":["10.8.1.9/32"]}
+	]`, staleID, settingsKey, subID)
+
+	for _, protocol := range []model.Protocol{model.AmneziaWG, model.WireGuard} {
+		t.Run(string(protocol), func(t *testing.T) {
+			initSubDB(t)
+			db := database.GetDB()
+			settings := `{"secretKey":"c2VydmVy","clients":` + clientsJSON + `}`
+			if protocol == model.AmneziaWG {
+				settings = `{"server":{"privateKey":"c2VydmVy","publicKey":"cHVi"},"clients":` + clientsJSON + `}`
+			}
+			ib := &model.Inbound{
+				UserId: 1, Tag: "meta-" + string(protocol), Enable: true, Listen: "203.0.113.50", Port: 51850,
+				Protocol: protocol, Remark: "meta", Settings: settings,
+			}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			rec := &model.ClientRecord{
+				Email: email, SubID: subID, UUID: freshID, Enable: true,
+				TotalGB: 5, ExpiryTime: expiry, Comment: "vip", LimitIP: 3,
+				PrivateKey: sharedKey, PublicKey: "shared-pub", AllowedIPs: "10.9.9.9/32",
+				PreSharedKey: "shared-psk", KeepAlive: 99,
+			}
+			other := &model.ClientRecord{
+				Email: "other-sub@awg", SubID: "other-sub", UUID: "22222222-2222-4333-8444-555555555555", Enable: true,
+			}
+			for _, row := range []*model.ClientRecord{rec, other} {
+				if err := db.Create(row).Error; err != nil {
+					t.Fatalf("create client %s: %v", row.Email, err)
+				}
+				if err := db.Create(&model.ClientInbound{ClientId: row.Id, InboundId: ib.Id}).Error; err != nil {
+					t.Fatalf("link %s: %v", row.Email, err)
+				}
+			}
+
+			s := &SubService{}
+			got := s.matchingClients(ib, subID)
+			if len(got) != 1 {
+				t.Fatalf("clients = %d, want the one normalized member: %+v", len(got), got)
+			}
+			c := got[0]
+			if c.Email != email || c.ID != freshID || c.SubID != subID || !c.Enable || c.TotalGB != 5 || c.ExpiryTime != expiry || c.Comment != "vip" || c.LimitIP != 3 {
+				t.Fatalf("normalized metadata = %+v", c)
+			}
+			if c.PrivateKey != settingsKey || c.PublicKey != "settings-pub" || c.PreSharedKey != "settings-psk" || c.KeepAliveSeconds() != 15 {
+				t.Fatalf("tunnel identity = key %q pub %q psk %q ka %d", c.PrivateKey, c.PublicKey, c.PreSharedKey, c.KeepAliveSeconds())
+			}
+			if !slices.Equal(c.AllowedIPs, []string{"10.8.1.2/32", "fd00::2/128"}) {
+				t.Fatalf("allowedIPs = %v, want this inbound's v4 and v6", c.AllowedIPs)
+			}
+			cached, ok := s.clientForLink(ib, email)
+			if !ok || cached.PrivateKey != settingsKey || cached.PreSharedKey != "settings-psk" || cached.KeepAliveSeconds() != 15 || !slices.Equal(cached.AllowedIPs, c.AllowedIPs) {
+				t.Fatalf("primed cache = %+v, ok %v", cached, ok)
+			}
+			if extra := s.matchingClients(ib, "nope"); len(extra) != 0 {
+				t.Fatalf("non-matching subId must yield 0 clients, got %d", len(extra))
+			}
+		})
+	}
+}

+ 3 - 8
internal/sub/service_dedup_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
@@ -18,10 +19,7 @@ import (
 func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 
 	const subId = "sub-dup"
 	const email = "[email protected]"
@@ -76,10 +74,7 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
 func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 
 	const subId = "s1"
 	const uuid = "11111111-2222-4333-8444-555555555555"

+ 2 - 6
internal/sub/service_info_node_test.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -15,12 +16,7 @@ import (
 
 func setupInfoNodeTestDB(t *testing.T) {
 	t.Helper()
-	if err := database.InitDB(t.TempDir() + "/test_infonode.db"); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() {
-		_ = database.CloseDB()
-	})
+	dbtest.InitDB(t, t.TempDir()+"/test_infonode.db")
 	db := database.GetDB()
 	if err := db.AutoMigrate(
 		&model.Inbound{},

+ 2 - 4
internal/sub/service_orphaned_stats_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
@@ -17,10 +18,7 @@ import (
 func TestStatsForClient_OrphanedInboundIdFallback(t *testing.T) {
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 
 	const email = "[email protected]"
 	const total = int64(100) * gb

+ 2 - 4
internal/sub/service_sort_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
@@ -16,10 +17,7 @@ import (
 func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 
 	const subId = "sub-sort"
 	db := database.GetDB()

+ 2 - 4
internal/sub/service_userinfo_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
@@ -12,10 +13,7 @@ import (
 func TestAggregateTrafficByEmails_FallsBackToClientLimits(t *testing.T) {
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 
 	const email = "[email protected]"
 	const totalBytes = int64(300) * 1024 * 1024 * 1024

+ 258 - 0
internal/sub/service_wireguard_test.go

@@ -1,7 +1,9 @@
 package sub
 
 import (
+	"fmt"
 	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -130,3 +132,259 @@ func TestGetInboundsBySubIdIncludesWireguard(t *testing.T) {
 		t.Fatalf("wireguard inbound not returned for subId: %+v", inbounds)
 	}
 }
+
+func mustWireguardKeypair(t *testing.T) (string, string) {
+	t.Helper()
+	priv, pub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("keypair: %v", err)
+	}
+	return priv, pub
+}
+
+func parseWireguardSubLink(t *testing.T, link string) *url.URL {
+	t.Helper()
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("parse wireguard link: %v\n got: %s", err, link)
+	}
+	if u.Scheme != "wireguard" {
+		t.Fatalf("scheme = %q, want wireguard (%s)", u.Scheme, link)
+	}
+	return u
+}
+
+// The shared clients row holds the last sync's tunnel identity. Each wireguard://
+// entry must keep its own key and both IPv4 and IPv6 addresses, in either sort order (#6641).
+func TestGetSubs_PreservesPerInboundWireGuardIdentity(t *testing.T) {
+	serverAPriv, serverAPub := mustWireguardKeypair(t)
+	serverBPriv, serverBPub := mustWireguardKeypair(t)
+	privA, _ := mustWireguardKeypair(t)
+	privB, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@wg"
+		subID      = "sub-wg-identity"
+		mergedAddr = "10.9.9.9/32,fd00:9::9/128"
+	)
+	nodes := []struct {
+		tag, listen, priv, serverPriv, serverPub string
+		port                                     int
+		allowed                                  []string
+	}{
+		{"wg-a", "203.0.113.10", privA, serverAPriv, serverAPub, 51820, []string{"10.1.0.2/32", "fd00:1::2/128"}},
+		{"wg-b", "203.0.113.11", privB, serverBPriv, serverBPub, 51821, []string{"10.2.0.2/32", "fd00:2::2/128"}},
+	}
+
+	for _, tc := range []struct {
+		name  string
+		sort  [2]int
+		order [2]int
+	}{
+		{name: "creation order", sort: [2]int{1, 2}, order: [2]int{0, 1}},
+		{name: "reversed subscription sort", sort: [2]int{2, 1}, order: [2]int{1, 0}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			initSubDB(t)
+			db := database.GetDB()
+			inbounds := make([]*model.Inbound, len(nodes))
+			for i, n := range nodes {
+				settings := fmt.Sprintf(
+					`{"secretKey":%q,"mtu":1420,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q,%q],"enable":true}]}`,
+					n.serverPriv, email, n.priv, n.allowed[0], n.allowed[1],
+				)
+				ib := &model.Inbound{
+					UserId: 1, Tag: n.tag, Enable: true, Listen: n.listen, Port: n.port,
+					Protocol: model.WireGuard, Remark: n.tag, Settings: settings, SubSortIndex: tc.sort[i],
+				}
+				if err := db.Create(ib).Error; err != nil {
+					t.Fatalf("create %s: %v", n.tag, err)
+				}
+				inbounds[i] = ib
+			}
+			rec := &model.ClientRecord{
+				Email: email, SubID: subID, Enable: true,
+				PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+				PreSharedKey: "sharedpsk", KeepAlive: 25,
+			}
+			if err := db.Create(rec).Error; err != nil {
+				t.Fatalf("create client: %v", err)
+			}
+			for _, ib := range inbounds {
+				if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+					t.Fatalf("link %s: %v", ib.Tag, err)
+				}
+			}
+
+			links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+			if err != nil {
+				t.Fatalf("GetSubs: %v", err)
+			}
+			if len(links) != len(nodes) {
+				t.Fatalf("links = %d, want %d: %v", len(links), len(nodes), links)
+			}
+			for outIdx, nodeIdx := range tc.order {
+				n := nodes[nodeIdx]
+				other := nodes[1-nodeIdx]
+				u := parseWireguardSubLink(t, links[outIdx])
+				if u.Host != fmt.Sprintf("%s:%d", n.listen, n.port) {
+					t.Fatalf("host = %q, want %s:%d", u.Host, n.listen, n.port)
+				}
+				if u.User.Username() != n.priv {
+					t.Fatalf("private key = %q, want inbound key %q", u.User.Username(), n.priv)
+				}
+				q := u.Query()
+				if got, want := q.Get("address"), strings.Join(n.allowed, ","); got != want {
+					t.Fatalf("address = %q, want %q", got, want)
+				}
+				if q.Get("publickey") != n.serverPub {
+					t.Fatalf("publickey = %q, want %q", q.Get("publickey"), n.serverPub)
+				}
+				if q.Get("presharedkey") != "" || q.Get("keepalive") != "" {
+					t.Fatalf("optional fields inherited shared values: %s", u.RawQuery)
+				}
+				if u.User.Username() == mergedPriv || strings.Contains(q.Get("address"), "10.9.9.9") || strings.Contains(q.Get("address"), other.allowed[0]) || strings.Contains(q.Get("address"), other.allowed[1]) {
+					t.Fatalf("link borrowed another tunnel identity: %s", links[outIdx])
+				}
+			}
+		})
+	}
+}
+
+// A peer missing from settings, or settings that do not parse, must not emit the
+// shared clients.wg_* identity. A sibling inbound with its own peer still does (#6641).
+func TestGetSubs_WireGuardUnavailableSettingsEmitNoSharedConfig(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	validPriv, _ := mustWireguardKeypair(t)
+	otherPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@wg"
+		subID      = "sub-wg-missing"
+		mergedAddr = "10.9.9.9/32,fd00:9::9/128"
+	)
+	validAllowed := []string{"10.4.0.2/32", "fd00:4::2/128"}
+	validSettings := fmt.Sprintf(
+		`{"secretKey":%q,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q,%q],"enable":true}]}`,
+		serverPriv, email, validPriv, validAllowed[0], validAllowed[1],
+	)
+	absentSettings := fmt.Sprintf(
+		`{"secretKey":%q,"clients":[{"email":"someone-else@wg","privateKey":%q,"allowedIPs":["10.8.9.9/32"],"enable":true}]}`,
+		serverPriv, otherPriv,
+	)
+	specs := []struct {
+		tag, listen, settings string
+		port                  int
+	}{
+		{"wg-bad-json", "203.0.113.31", `{not-json`, 51831},
+		{"wg-absent-peer", "203.0.113.32", absentSettings, 51832},
+		{"wg-valid", "203.0.113.33", validSettings, 51833},
+	}
+	inbounds := make([]*model.Inbound, len(specs))
+	for i, sp := range specs {
+		ib := &model.Inbound{
+			UserId: 1, Tag: sp.tag, Enable: true, Listen: sp.listen, Port: sp.port,
+			Protocol: model.WireGuard, Remark: sp.tag, Settings: sp.settings, SubSortIndex: i + 1,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create %s: %v", sp.tag, err)
+		}
+		inbounds[i] = ib
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	for _, ib := range inbounds {
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("link %s: %v", ib.Tag, err)
+		}
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1 (absent and malformed inbounds must not emit the shared row): %q", len(links), links)
+	}
+	u := parseWireguardSubLink(t, links[0])
+	if u.Host != "203.0.113.33:51833" {
+		t.Fatalf("host = %q, want the valid inbound", u.Host)
+	}
+	if u.User.Username() != validPriv {
+		t.Fatalf("private key = %q, want inbound key", u.User.Username())
+	}
+	if got, want := u.Query().Get("address"), strings.Join(validAllowed, ","); got != want {
+		t.Fatalf("address = %q, want %q", got, want)
+	}
+	if u.Query().Get("publickey") != serverPub || u.Query().Get("presharedkey") != "" || u.Query().Get("keepalive") != "" {
+		t.Fatalf("query borrowed shared or foreign tunnel fields: %s", u.RawQuery)
+	}
+}
+
+// Explicit empty preshared key and keepalive must not inherit the shared row (#6641).
+func TestGetSubs_WireGuardEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	clientPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email = "optional@wg"
+		subID = "sub-wg-optional"
+	)
+	allowed := []string{"10.5.0.2/32", "fd00:5::2/128"}
+	settings := fmt.Sprintf(
+		`{"secretKey":%q,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q,%q],"preSharedKey":"","keepAlive":0,"enable":true}]}`,
+		serverPriv, email, clientPriv, allowed[0], allowed[1],
+	)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "wg-optional", Enable: true, Listen: "203.0.113.40", Port: 51840,
+		Protocol: model.WireGuard, Remark: "wg-optional", Settings: settings,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: "10.9.9.9/32,fd00:9::9/128",
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("link client: %v", err)
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %q", len(links), links)
+	}
+	u := parseWireguardSubLink(t, links[0])
+	if u.User.Username() != clientPriv {
+		t.Fatalf("private key = %q, want inbound key", u.User.Username())
+	}
+	if got, want := u.Query().Get("address"), strings.Join(allowed, ","); got != want {
+		t.Fatalf("address = %q, want %q", got, want)
+	}
+	if u.Query().Get("publickey") != serverPub {
+		t.Fatalf("publickey = %q, want %q", u.Query().Get("publickey"), serverPub)
+	}
+	if u.Query().Get("presharedkey") != "" || u.Query().Get("keepalive") != "" || strings.Contains(u.RawQuery, "sharedpsk") || strings.Contains(u.Query().Get("address"), "10.9.9.9") || strings.Contains(u.Query().Get("address"), "fd00:9::9") {
+		t.Fatalf("link inherited shared tunnel fields: %s", links[0])
+	}
+}

+ 32 - 0
internal/sub/sub.go

@@ -253,6 +253,37 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	happCfg.AutoConnectType, _ = s.settingService.GetSubHappAutoConnectType()
 	happCfg.PerAppMode, _ = s.settingService.GetSubHappPerAppMode()
 	happCfg.PerAppList, _ = s.settingService.GetSubHappPerAppList()
+	happCfg.LocalProxyAuth, _ = s.settingService.GetSubHappLocalProxyAuth()
+
+	incyCfg := IncyConfig{}
+	incyCfg.AutoDetect, _ = s.settingService.GetSubIncyAppAutoDetect()
+	incyCfg.ProfileDescription, _ = s.settingService.GetSubIncyProfileDescription()
+	incyCfg.SortOrder, _ = s.settingService.GetSubIncySortOrder()
+	incyCfg.SupportEmail, _ = s.settingService.GetSubIncySupportEmail()
+	incyCfg.AnnounceUrl, _ = s.settingService.GetSubIncyAnnounceUrl()
+	incyCfg.PremiumUrl, _ = s.settingService.GetSubIncyPremiumUrl()
+	incyCfg.BannerText, _ = s.settingService.GetSubIncyBannerText()
+	incyCfg.BannerButtonText, _ = s.settingService.GetSubIncyBannerButtonText()
+	incyCfg.BannerButtonUrl, _ = s.settingService.GetSubIncyBannerButtonUrl()
+	incyCfg.BannerBgColor, _ = s.settingService.GetSubIncyBannerBgColor()
+	incyCfg.BannerButtonColor, _ = s.settingService.GetSubIncyBannerButtonColor()
+	incyCfg.HideUrl, _ = s.settingService.GetSubIncyHideUrl()
+	incyCfg.HideCheck, _ = s.settingService.GetSubIncyHideCheck()
+	incyCfg.NoLimitEnabled, _ = s.settingService.GetSubIncyNoLimitEnabled()
+	incyCfg.PerAppProxyEnable, _ = s.settingService.GetSubIncyPerAppEnable()
+	incyCfg.PerAppProxyMode, _ = s.settingService.GetSubIncyPerAppMode()
+	incyCfg.PerAppProxyList, _ = s.settingService.GetSubIncyPerAppList()
+	incyCfg.FragmentationEnable, _ = s.settingService.GetSubIncyFragmentationEnable()
+	incyCfg.FragmentationLength, _ = s.settingService.GetSubIncyFragmentLength()
+	incyCfg.FragmentationInterval, _ = s.settingService.GetSubIncyFragmentInterval()
+	incyCfg.FragmentationPackets, _ = s.settingService.GetSubIncyFragmentPackets()
+	incyCfg.NoisesEnable, _ = s.settingService.GetSubIncyNoisesEnable()
+	incyCfg.NoisesType, _ = s.settingService.GetSubIncyNoisesType()
+	incyCfg.NoisesPacket, _ = s.settingService.GetSubIncyNoisesPacket()
+	incyCfg.NoisesDelay, _ = s.settingService.GetSubIncyNoisesDelay()
+	incyCfg.ServerAddressResolveEnable, _ = s.settingService.GetSubIncyResolveEnable()
+	incyCfg.ServerAddressResolveDnsDomain, _ = s.settingService.GetSubIncyResolveDnsDomain()
+	incyCfg.ServerAddressResolveDnsIp, _ = s.settingService.GetSubIncyResolveDnsIp()
 
 	// set per-request localizer from headers/cookies
 	engine.Use(locale.LocalizerMiddleware())
@@ -339,6 +370,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBRoutingRules(SubRoutingRules),
 		WithSUBHideSettings(SubHideSettings),
 		WithSUBHappConfig(happCfg),
+		WithSUBIncyConfig(incyCfg),
 		WithSUBIncyEnableRouting(SubIncyEnableRouting),
 		WithSUBIncyRoutingRules(SubIncyRoutingRules),
 	)

+ 2 - 4
internal/sub/sub_scale_test.go

@@ -16,6 +16,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -38,10 +39,7 @@ func setupScaleSubDB(t *testing.T) {
 	}
 	switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
 	case "1", "true", "yes":
-		if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
-			t.Fatalf("InitDB(sqlite): %v", err)
-		}
-		t.Cleanup(func() { _ = database.CloseDB() })
+		dbtest.InitDB(t, filepath.Join(t.TempDir(), "scale.db"))
 		return
 	}
 	t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")

+ 95 - 0
internal/testpg/isolate.go

@@ -0,0 +1,95 @@
+package testpg
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+)
+
+const (
+	dbTypeEnv = "XUI_DB_TYPE"
+	dbDSNEnv  = "XUI_DB_DSN"
+)
+
+// IsolatePackage gives one test package its own PostgreSQL schema: package test
+// binaries run concurrently, and sharing public lets their migrations race.
+func IsolatePackage(packageName string) (func(), error) {
+	if os.Getenv(dbTypeEnv) != "postgres" {
+		return func() {}, nil
+	}
+	baseDSN := strings.TrimSpace(os.Getenv(dbDSNEnv))
+	if baseDSN == "" {
+		return func() {}, nil
+	}
+
+	suffix := make([]byte, 8)
+	if _, err := rand.Read(suffix); err != nil {
+		return nil, fmt.Errorf("generate PostgreSQL test schema suffix: %w", err)
+	}
+	schema := fmt.Sprintf("xui_%s_%d_%s", sanitize(packageName), os.Getpid(), hex.EncodeToString(suffix))
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	admin, err := pgxpool.New(ctx, baseDSN)
+	if err != nil {
+		return nil, fmt.Errorf("open PostgreSQL test database: %w", err)
+	}
+	if _, err := admin.Exec(ctx, "CREATE SCHEMA "+pgx.Identifier{schema}.Sanitize()); err != nil {
+		admin.Close()
+		return nil, fmt.Errorf("create PostgreSQL test schema: %w", err)
+	}
+	isolatedDSN, err := withSearchPath(baseDSN, schema)
+	if err != nil {
+		admin.Close()
+		return nil, err
+	}
+	if err := os.Setenv(dbDSNEnv, isolatedDSN); err != nil {
+		admin.Close()
+		return nil, fmt.Errorf("set isolated PostgreSQL test DSN: %w", err)
+	}
+
+	return func() {
+		cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cleanupCancel()
+		_, _ = admin.Exec(cleanupCtx, "DROP SCHEMA "+pgx.Identifier{schema}.Sanitize()+" CASCADE")
+		admin.Close()
+		_ = os.Setenv(dbDSNEnv, baseDSN)
+	}, nil
+}
+
+func withSearchPath(dsn, schema string) (string, error) {
+	u, err := url.Parse(dsn)
+	if err == nil && (u.Scheme == "postgres" || u.Scheme == "postgresql") {
+		query := u.Query()
+		query.Set("search_path", schema)
+		u.RawQuery = query.Encode()
+		return u.String(), nil
+	}
+	if strings.ContainsAny(schema, " '[]=\\") {
+		return "", fmt.Errorf("unsafe PostgreSQL test schema name")
+	}
+	return strings.TrimSpace(dsn) + " search_path=" + schema, nil
+}
+
+func sanitize(value string) string {
+	var result strings.Builder
+	for _, r := range strings.ToLower(value) {
+		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' {
+			result.WriteRune(r)
+		} else {
+			result.WriteByte('_')
+		}
+	}
+	if result.Len() == 0 {
+		return "pkg"
+	}
+	return result.String()
+}

+ 2 - 4
internal/web/controller/api_auth_test.go

@@ -15,6 +15,7 @@ import (
 	"github.com/gin-gonic/gin"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -31,10 +32,7 @@ func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
 	gin.SetMode(gin.TestMode)
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 	engine := gin.New()
 	store := cookie.NewStore([]byte("api-auth-test-secret"))
 	engine.Use(sessions.Sessions("3x-ui", store))

+ 19 - 4
internal/web/controller/client.go

@@ -60,6 +60,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/happLink/:id", a.generateHappLink)
 
 	g.POST("/add", a.create)
+	g.POST("/renewalPreview", a.renewalPreview)
 	g.POST("/update/:email", a.update)
 	g.POST("/del/:email", a.delete)
 	g.POST("/:email/attach", a.attach)
@@ -101,6 +102,16 @@ func (a *ClientController) list(c *gin.Context) {
 	jsonObj(c, rows, nil)
 }
 
+func (a *ClientController) renewalPreview(c *gin.Context) {
+	var request service.ClientRenewalPreviewRequest
+	if err := c.ShouldBindJSON(&request); err != nil {
+		jsonObj(c, nil, err)
+		return
+	}
+	preview, err := a.clientService.PreviewRenewal(request, &a.settingService)
+	jsonObj(c, preview, err)
+}
+
 func (a *ClientController) listPaged(c *gin.Context) {
 	var params service.ClientPageParams
 	if err := c.ShouldBindQuery(&params); err != nil {
@@ -506,15 +517,19 @@ func (a *ClientController) importClients(c *gin.Context) {
 		return
 	}
 	result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items)
+	// Flagged before the error check: a failed traffic restore still leaves the
+	// clients created before it committed, and those need the restart and refresh.
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	if needRestart || result.Created > 0 || err == nil {
+		notifyClientsChanged()
+	}
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
 	jsonObj(c, result, nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-	notifyClientsChanged()
 }
 
 func (a *ClientController) delOrphans(c *gin.Context) {

+ 50 - 4
internal/web/controller/client_partial_apply_test.go

@@ -11,6 +11,7 @@ import (
 	"github.com/gin-gonic/gin"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -22,10 +23,7 @@ func seedPartlyApplyingClient(t *testing.T, email string, basePort int) (healthy
 	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() })
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
 
 	db := database.GetDB()
 	ids := make([]int, 0, 2)
@@ -124,6 +122,54 @@ func TestDeleteHandlerFlagsRestartOnPartialApply(t *testing.T) {
 	}
 }
 
+// TestImportHandlerFlagsRestartWhenTrafficRestoreFails: the traffic restore runs
+// after the clients are committed, so its failure must not discard their restart.
+func TestImportHandlerFlagsRestartWhenTrafficRestoreFails(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
+	db := database.GetDB()
+	ib := &model.Inbound{
+		UserId: 1, Enable: true, Port: 43340, Tag: "in-import-partial",
+		Protocol: model.VLESS, Settings: `{"clients": []}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	trigger := `CREATE TRIGGER fail_traffic_restore BEFORE UPDATE OF up ON client_traffics
+		BEGIN SELECT RAISE(ABORT, 'injected traffic restore failure'); END`
+	if err := db.Exec(trigger).Error; err != nil {
+		t.Fatalf("create failure trigger: %v", err)
+	}
+
+	const email = "[email protected]"
+	data, err := json.Marshal([]service.ClientCreatePayload{{
+		Client:     model.Client{Email: email, SubID: "sub-import-partial", Enable: true},
+		InboundIds: []int{ib.Id},
+		Traffic:    &service.ClientPortableTraffic{Up: 5, Down: 6},
+	}})
+	if err != nil {
+		t.Fatalf("marshal import data: %v", err)
+	}
+	a := &ClientController{}
+	a.xrayService.IsNeedRestartAndSetFalse()
+	c, w := postCtx(t, "", importClientsRequest{Data: string(data)})
+	a.importClients(c)
+
+	assertPartialApply(t, w)
+	var created int64
+	if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&created).Error; err != nil {
+		t.Fatalf("count imported client: %v", err)
+	}
+	if created != 1 {
+		t.Fatalf("imported client count=%d, want 1 committed before the restore failed", created)
+	}
+	if !a.xrayService.IsNeedRestartAndSetFalse() {
+		t.Fatal("a failed traffic restore left the imported clients' Xray restart unflagged")
+	}
+}
+
 // TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
 func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
 	const email = "[email protected]"

+ 122 - 0
internal/web/controller/client_renewal_preview_test.go

@@ -0,0 +1,122 @@
+package controller
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestClientRenewalPreviewHTTP(t *testing.T) {
+	for _, tt := range []struct {
+		name, zone, expiry, renewAt, validThrough, next string
+		wantError                                       string
+		reset, day, weekday, max, count                 int
+		periods                                         int
+		canRenew, delayed, invalid                      bool
+	}{
+		{name: "monthly uses Taipei and preserves legacy precedence", zone: "Asia/Taipei", expiry: "2030-01-01T00:00:00+08:00", renewAt: "2030-01-01T00:00:00+08:00", validThrough: "2029-12-31T23:59:59+08:00", next: "2030-02-01T00:00:00+08:00", reset: 7, day: 1, canRenew: true},
+		{name: "31st clamps in February", zone: "UTC", expiry: "2030-01-31T00:00:00Z", renewAt: "2030-01-31T00:00:00Z", validThrough: "2030-01-30T23:59:59Z", next: "2030-02-28T00:00:00Z", day: 31, canRenew: true},
+		{name: "31st returns after February", zone: "UTC", expiry: "2030-02-28T00:00:00Z", renewAt: "2030-02-28T00:00:00Z", validThrough: "2030-02-27T23:59:59Z", next: "2030-03-31T00:00:00Z", day: 31, canRenew: true},
+		{name: "leap February", zone: "UTC", expiry: "2028-01-31T00:00:00Z", renewAt: "2028-01-31T00:00:00Z", validThrough: "2028-01-30T23:59:59Z", next: "2028-02-29T00:00:00Z", day: 31, canRenew: true},
+		{name: "weekly crosses New York daylight saving", zone: "America/New_York", expiry: "2030-03-10T00:00:00-05:00", renewAt: "2030-03-10T00:00:00-05:00", validThrough: "2030-03-09T23:59:59-05:00", next: "2030-03-17T00:00:00-04:00", weekday: 7, canRenew: true},
+		{name: "interval preserves 168 hours", zone: "America/New_York", expiry: "2030-03-10T00:00:00-05:00", renewAt: "2030-03-10T00:00:00-05:00", validThrough: "2030-03-09T23:59:59-05:00", next: "2030-03-17T01:00:00-04:00", reset: 7, canRenew: true},
+		{name: "inclusive month end charges full month", zone: "UTC", expiry: "2030-01-31T23:59:59Z", renewAt: "2030-02-01T00:00:00Z", validThrough: "2030-01-31T23:59:58Z", next: "2030-03-01T00:00:00Z", day: 1, max: 1, canRenew: true},
+		{name: "inclusive week end charges full week", zone: "UTC", expiry: "2030-01-06T23:59:59Z", renewAt: "2030-01-07T00:00:00Z", validThrough: "2030-01-06T23:59:58Z", next: "2030-01-14T00:00:00Z", weekday: 1, max: 1, canRenew: true},
+		{name: "partial offline catch-up remains expired", zone: "UTC", expiry: "2026-03-01T00:00:00Z", renewAt: "2026-03-01T00:00:00Z", validThrough: "2026-02-28T23:59:59Z", next: "2026-03-08T00:00:00Z", weekday: 7, max: 3, count: 2, periods: 1},
+		{name: "arbitrary initial cutoff is not silently realigned", zone: "UTC", expiry: "2030-01-08T12:00:00Z", renewAt: "2030-01-08T12:00:00Z", validThrough: "2030-01-08T11:59:59Z", next: "2030-01-14T00:00:00Z", weekday: 1, canRenew: true},
+		{name: "cap exhausted", zone: "UTC", expiry: "2030-01-01T00:00:00Z", renewAt: "2030-01-01T00:00:00Z", validThrough: "2029-12-31T23:59:59Z", day: 1, max: 3, count: 3},
+		{name: "unset cutoff only suggests a boundary", zone: "UTC", weekday: 1},
+		{name: "first-use waits for activation", zone: "UTC", expiry: "first-use", weekday: 1, delayed: true},
+		{name: "disabled ignores absolute expiry", zone: "UTC", expiry: "2030-01-01T00:00:00Z"},
+		{name: "invalid weekday", zone: "UTC", weekday: 8, invalid: true, wantError: "client resetWeekday must be between 0 and 7, got: 8\n"},
+		{name: "conflicting weekly and monthly", zone: "UTC", day: 1, weekday: 1, invalid: true, wantError: "client weekly renewal cannot be combined with reset or resetDay\n"},
+		{name: "conflicting weekly and interval", zone: "UTC", reset: 7, weekday: 1, invalid: true, wantError: "client weekly renewal cannot be combined with reset or resetDay\n"},
+		{name: "negative count", zone: "UTC", count: -1, invalid: true, wantError: "renewal preview reset and resetCount must not be negative\n"},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
+			db := database.GetDB()
+			if err := db.Create(&model.Setting{Key: "timeLocation", Value: tt.zone}).Error; err != nil {
+				t.Fatal(err)
+			}
+			snapshot := xray.ClientTraffic{Email: "preview-sentinel", ExpiryTime: 1893456000000, ResetCount: 7, Up: 111, Down: 222, Enable: true}
+			if err := db.Create(&snapshot).Error; err != nil {
+				t.Fatal(err)
+			}
+			request := service.ClientRenewalPreviewRequest{Reset: tt.reset, ResetDay: tt.day, ResetWeekday: tt.weekday, ResetMax: tt.max, ResetCount: tt.count}
+			if tt.expiry == "first-use" {
+				request.ExpiryTime = -7 * 86400000
+			} else if tt.expiry != "" {
+				at, err := time.Parse(time.RFC3339, tt.expiry)
+				if err != nil {
+					t.Fatal(err)
+				}
+				request.ExpiryTime = at.UnixMilli()
+			}
+			payload, err := json.Marshal(request)
+			if err != nil {
+				t.Fatal(err)
+			}
+			router := gin.New()
+			NewClientController(router.Group("/panel/api/clients"))
+			w := httptest.NewRecorder()
+			r := httptest.NewRequest(http.MethodPost, "/panel/api/clients/renewalPreview", bytes.NewReader(payload))
+			r.Header.Set("Content-Type", "application/json")
+			router.ServeHTTP(w, r)
+			var msg struct {
+				Success bool                         `json:"success"`
+				Msg     string                       `json:"msg"`
+				Obj     service.ClientRenewalPreview `json:"obj"`
+			}
+			if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
+				t.Fatal(err)
+			}
+			if w.Code != http.StatusOK || msg.Success == tt.invalid {
+				t.Fatalf("status/body = %d/%s", w.Code, w.Body.String())
+			}
+			if tt.invalid && msg.Msg != " ("+tt.wantError+")" {
+				t.Fatalf("validation error = %q, want wrapped error %q", msg.Msg, tt.wantError)
+			}
+			if !tt.invalid {
+				got := msg.Obj
+				if got.TimeZone != tt.zone || got.RenewAt != tt.renewAt || got.ValidThrough != tt.validThrough || got.NextExpiry != tt.next || got.CanRenew != tt.canRenew || got.DelayedStart != tt.delayed {
+					t.Fatalf("preview = %+v, want boundary/valid/next %q/%q/%q", got, tt.renewAt, tt.validThrough, tt.next)
+				}
+				wantRenewals := 0
+				if tt.canRenew {
+					wantRenewals = 1
+				}
+				if tt.periods > 0 {
+					wantRenewals = tt.periods
+				}
+				if got.Renewals != wantRenewals {
+					t.Fatalf("periods = %d, want %d", got.Renewals, wantRenewals)
+				}
+				if tt.weekday > 0 || tt.day > 0 {
+					if got.SuggestedExpiryTime <= time.Now().UnixMilli() || got.SuggestedExpiry == "" {
+						t.Fatalf("missing future suggestion: %+v", got)
+					}
+				}
+			}
+			var after xray.ClientTraffic
+			if err := db.Where("email = ?", snapshot.Email).First(&after).Error; err != nil {
+				t.Fatal(err)
+			}
+			if after != snapshot {
+				t.Fatalf("read-only preview mutated traffic: %+v", after)
+			}
+		})
+	}
+}

Some files were not shown because too many files changed in this diff