15 Коміти 7a2179535a ... 56b0be0b6a

Автор SHA1 Опис Дата
  MHSanaei 56b0be0b6a fix(lint): use errors.Is for io.EOF comparison in sys_linux 5 годин тому
  MHSanaei 9b8a0c9b17 feat(groups): reset group traffic without touching client counters 6 годин тому
  MHSanaei d1c0d77023 chore(ci): bump golangci-lint action to v9 6 годин тому
  MHSanaei 63fca9ef88 docs: correct false RTL claim and stale Vite version in CONTRIBUTING.md 6 годин тому
  MHSanaei 2e851978e6 chore: add Makefile as canonical task runner 6 годин тому
  MHSanaei fa1a19c03c style: adopt golangci-lint v2 and resolve all findings 6 годин тому
  MHSanaei 7efa0d9ddd docs: add CLAUDE.md agent guides for root and frontend 6 годин тому
  MHSanaei d12b186a69 test(sub): align identity-token test with first-link-only EMAIL 8 годин тому
  MHSanaei 39eb5baf42 fix(inbound): convert legacy externalProxy to hosts on import 8 годин тому
  MHSanaei 876d55f274 fix(sub): show {{EMAIL}} on first sub-body link only 9 годин тому
  Nikan Zeyaei 1bad2fcba1 feat(backup): prefix backup filenames with date and time (#5606) 10 годин тому
  MHSanaei 4c177f0cf1 fix(shadowsocks): send per-user Account for SS-2022 runtime AddUser 10 годин тому
  MHSanaei 797b08cd07 fix(balancers): create burst observer for random/roundRobin with fallbackTag 10 годин тому
  MHSanaei 439245d42b feat(inbounds): apply remark template to Export all inbound links 11 годин тому
  MHSanaei 535b89a352 fix(routing): write lowercase L4 network to xray config, display uppercase in UI 11 годин тому
100 змінених файлів з 1345 додано та 328 видалено
  1. 15 0
      .github/workflows/ci.yml
  2. 53 0
      .golangci.yml
  3. 101 0
      CLAUDE.md
  4. 2 2
      CONTRIBUTING.md
  5. 75 0
      Makefile
  6. 60 0
      frontend/CLAUDE.md
  7. 86 0
      frontend/public/openapi.json
  8. 15 0
      frontend/src/pages/api-docs/endpoints.ts
  9. 6 9
      frontend/src/pages/groups/GroupsPage.tsx
  10. 4 15
      frontend/src/pages/inbounds/InboundsPage.tsx
  11. 26 12
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  12. 1 1
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  13. 1 1
      frontend/src/pages/xray/routing/helpers.ts
  14. 1 1
      frontend/src/pages/xray/routing/useRoutingColumns.tsx
  15. 56 2
      frontend/src/test/balancer-observatory-sync.test.ts
  16. 2 1
      internal/database/api_token_timestamp_test.go
  17. 42 26
      internal/database/db.go
  18. 8 11
      internal/database/dump_sqlite.go
  19. 2 2
      internal/database/migrate_data.go
  20. 2 0
      internal/database/model/model.go
  21. 2 1
      internal/eventbus/bus_test.go
  22. 2 1
      internal/logger/logger.go
  23. 11 6
      internal/mtproto/manager.go
  24. 2 1
      internal/mtproto/process.go
  25. 4 3
      internal/mtproto/process_windows.go
  26. 3 3
      internal/sub/clash_service.go
  27. 3 2
      internal/sub/controller.go
  28. 43 0
      internal/sub/export_all_links_test.go
  29. 2 1
      internal/sub/external_subscription.go
  30. 2 1
      internal/sub/external_subscription_test.go
  31. 7 7
      internal/sub/json_service.go
  32. 9 0
      internal/sub/links.go
  33. 10 1
      internal/sub/remark_vars.go
  34. 32 3
      internal/sub/remark_vars_test.go
  35. 40 9
      internal/sub/service.go
  36. 3 3
      internal/sub/sub.go
  37. 1 1
      internal/tunnelmonitor/monitor.go
  38. 2 1
      internal/tunnelmonitor/monitor_test.go
  39. 2 1
      internal/util/common/gorecover_test.go
  40. 1 1
      internal/util/random/random_test.go
  41. 2 1
      internal/util/sys/sys_linux.go
  42. 2 1
      internal/util/sys/sys_windows.go
  43. 0 2
      internal/web/controller/api.go
  44. 19 0
      internal/web/controller/group.go
  45. 14 1
      internal/web/controller/inbound.go
  46. 6 7
      internal/web/controller/server.go
  47. 8 7
      internal/web/job/check_client_ip_job.go
  48. 2 1
      internal/web/job/check_client_ip_job_integration_test.go
  49. 1 4
      internal/web/job/check_cpu_usage.go
  50. 1 4
      internal/web/job/check_memory_usage.go
  51. 4 4
      internal/web/job/clear_logs_job.go
  52. 2 2
      internal/web/job/clear_logs_job_test.go
  53. 1 1
      internal/web/job/xray_traffic_job.go
  54. 1 1
      internal/web/locale/locale.go
  55. 1 1
      internal/web/network/auto_https_conn.go
  56. 30 0
      internal/web/service/backup_filename_test.go
  57. 8 4
      internal/web/service/client_apply_field_test.go
  58. 1 0
      internal/web/service/client_bulk.go
  59. 4 2
      internal/web/service/client_bulk_flow_test.go
  60. 1 0
      internal/web/service/client_crud.go
  61. 127 0
      internal/web/service/client_group_reset_test.go
  62. 45 6
      internal/web/service/client_groups.go
  63. 1 0
      internal/web/service/client_portable.go
  64. 1 1
      internal/web/service/client_traffic.go
  65. 1 0
      internal/web/service/del_shared_email_runtime_test.go
  66. 6 5
      internal/web/service/email/email.go
  67. 2 1
      internal/web/service/fallback.go
  68. 20 9
      internal/web/service/inbound.go
  69. 1 0
      internal/web/service/inbound_clients.go
  70. 3 3
      internal/web/service/inbound_disable.go
  71. 103 0
      internal/web/service/inbound_import_external_proxy_test.go
  72. 6 5
      internal/web/service/inbound_migration.go
  73. 2 2
      internal/web/service/inbound_node.go
  74. 12 0
      internal/web/service/inbound_sublink.go
  75. 8 7
      internal/web/service/inbound_traffic.go
  76. 1 1
      internal/web/service/inbound_update_tag_test.go
  77. 16 7
      internal/web/service/integration/nord.go
  78. 9 8
      internal/web/service/integration/warp.go
  79. 1 1
      internal/web/service/node.go
  80. 3 0
      internal/web/service/node_bulk_dispatch_test.go
  81. 2 1
      internal/web/service/node_client_traffic_sum_test.go
  82. 1 0
      internal/web/service/node_mtls_test.go
  83. 2 1
      internal/web/service/outbound/outbound.go
  84. 8 8
      internal/web/service/outbound/probe_http.go
  85. 2 2
      internal/web/service/outbound_subscription.go
  86. 14 5
      internal/web/service/panel/panel.go
  87. 6 7
      internal/web/service/panel/user.go
  88. 4 4
      internal/web/service/panel/websocket.go
  89. 7 9
      internal/web/service/port_conflict_test.go
  90. 54 27
      internal/web/service/server.go
  91. 1 0
      internal/web/service/setting.go
  92. 1 0
      internal/web/service/sync_scale_postgres_test.go
  93. 2 2
      internal/web/service/tgbot/tgbot.go
  94. 9 10
      internal/web/service/tgbot/tgbot_client.go
  95. 1 5
      internal/web/service/tgbot/tgbot_inbound.go
  96. 2 2
      internal/web/service/tgbot/tgbot_report.go
  97. 8 11
      internal/web/service/tgbot/tgbot_router.go
  98. 3 3
      internal/web/service/xray.go
  99. 1 1
      internal/web/service/xray_setting.go
  100. 2 2
      internal/web/translation/ar-EG.json

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

@@ -102,6 +102,21 @@ jobs:
           go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
           go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
 
+  golangci:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
+      - name: golangci-lint
+        uses: golangci/golangci-lint-action@v9
+        with:
+          version: latest
+
   frontend:
     runs-on: ubuntu-latest
     steps:

+ 53 - 0
.golangci.yml

@@ -0,0 +1,53 @@
+version: "2"
+
+run:
+  build-tags: []
+  timeout: 5m
+
+linters:
+  default: standard
+  enable:
+    - bodyclose
+    - errorlint
+    - noctx
+    - misspell
+    - rowserrcheck
+    - sqlclosecheck
+    - unconvert
+    - usestdlibvars
+  exclusions:
+    generated: lax
+    presets:
+      - std-error-handling
+    paths:
+      - frontend
+      - internal/web/dist
+    rules:
+      - path: _test\.go
+        linters:
+          - errcheck
+          - bodyclose
+          - noctx
+      # tools/openapigen relies on go/parser.ParseDir; migrating it to
+      # golang.org/x/tools/go/packages is a generator change, out of scope here.
+      - linters:
+          - staticcheck
+        text: "SA1019: parser.ParseDir"
+      # ST1005 (capitalized error strings) conflicts with intentional
+      # user-facing error copy that tests assert verbatim.
+      - linters:
+          - staticcheck
+        text: "ST1005:"
+
+formatters:
+  enable:
+    - gofumpt
+    - goimports
+  settings:
+    goimports:
+      local-prefixes:
+        - github.com/mhsanaei/3x-ui
+  exclusions:
+    paths:
+      - frontend
+      - internal/web/dist

+ 101 - 0
CLAUDE.md

@@ -0,0 +1,101 @@
+# CLAUDE.md
+
+Operational guide for AI agents working in this repo. Long-form human docs:
+`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
+Read those before large changes. This file is the short, must-follow version.
+
+## Stack
+- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
+  Runs Xray-core as a managed child process (`internal/xray/process.go`) and
+  imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
+  API. MTProto inbounds run a second managed child — the `mtg` binary
+  (`internal/mtproto/`) — outside Xray.
+- Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
+  Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
+  driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.
+- Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in `frontend/`,
+  built into `internal/web/dist/` (gitignored) and embedded via `embed.FS`.
+
+## Repo map
+- `main.go` — entry point + `x-ui` CLI (run, migrate, migrate-db, setting, cert).
+- `internal/config/` — env parsing (XUI_DEBUG, XUI_LOG_LEVEL, XUI_LOG_FOLDER,
+  XUI_BIN_FOLDER, XUI_SKIP_HSTS, XUI_PORT, XUI_DB_*).
+- `internal/database/` + `internal/database/model/` — GORM schema (Inbound,
+  Client, Setting, User), inbound Protocol enum, AutoMigrate + hand-written
+  migrations in `db.go`.
+- `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
+- `internal/mtproto/` — MTProto inbounds via the bundled `mtg` binary.
+- `internal/sub/` — subscription server (raw / JSON / Clash).
+- `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
+  cpu.high, memory.high, login.attempt).
+- `internal/logger/`, `internal/util/` (link, crypto, sys, ldap, …),
+  `internal/tunnelmonitor/` — shared infrastructure.
+- `internal/web/` — Gin server (embeds `dist/` + `translation/`).
+  - `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
+  - `service/` — business logic (InboundService, SettingService, XrayService,
+    node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
+  - `job/` — cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP).
+  - `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
+    `runtime/` (master/sub-node over mTLS), `websocket/`.
+  - `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
+- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
+- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
+  into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
+  (`frontend/public/openapi.json`) is assembled from those + `endpoints.ts` by
+  `frontend/scripts/build-openapi.mjs`.
+
+## Hard rules (non-negotiable)
+- NO `//` line comments in committed Go/TS. Names carry meaning; rename instead
+  of annotating. Exempt: `//go:build`, `//go:generate`, and other directives.
+  HTML `<!-- -->` is fine. (A linter cannot enforce this — you must.)
+- New `g.POST`/`g.GET` in `internal/web/controller/` REQUIRES a matching entry
+  in `frontend/src/pages/api-docs/endpoints.ts`, then `make gen` (or
+  `cd frontend && npm run gen`). It is a hand-maintained registry — nothing checks
+  it against the Go routes, so an omitted route silently vanishes from the docs.
+- Response examples come from Go struct `example:` tags via `tools/openapigen` —
+  never hand-write them. A new struct must be added to openapigen's `StructAllow`
+  allowlist (`tools/openapigen/main.go`) or it is silently omitted from
+  schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
+- A new English i18n key must be added to EVERY locale JSON in
+  `internal/web/translation/` (13 files). Missing keys fall back to en-US (or
+  render the raw key if absent there too); nothing fails the build, so they are
+  easy to miss.
+- DB / model changes require a migration in `internal/database/db.go`.
+- Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `docs`,
+  `style`): `<area>: short imperative summary`, then a body explaining the why.
+
+## 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.
+- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
+
+## Frontend conventions (summary; full version in frontend/CLAUDE.md)
+- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
+- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
+  `src/schemas/` are the source of truth; infer types with `z.infer`, never
+  hand-write. Do not edit `src/generated/`.
+- Editing `frontend/src` does NOT change what users see until the Vite build is
+  regenerated into `internal/web/dist/`. In `XUI_DEBUG=true`, HTML is served from
+  the frozen embedded FS but JS/CSS off disk — after `npm run build` you MUST
+  restart `go run .` or you get a blank page with 404s.
+- After touching share-link logic (`src/lib/xray/`), run `npm run test` (golden
+  fixtures); regenerate snapshots (`npx vitest run -u`) only for intentional
+  output changes, never to make a red test green.
+
+## Build, test, verify
+Run `make help` for all targets. The full local gate that mirrors CI:
+
+    make verify
+
+Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
+`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
+
+## Definition of done (before opening a PR)
+1. `make gen` and confirm `git diff` on `frontend/src/generated` +
+   `frontend/public/openapi.json` is clean.
+2. `make verify` passes.
+3. Diff is focused; refactors are separate from feature work.

+ 2 - 2
CONTRIBUTING.md

@@ -184,11 +184,11 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
 - **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
 - **Function components + hooks** everywhere. No class components.
 - **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
-- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
+- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
 - **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
 - **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
 - **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
-- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
+- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
 
 ### Project layout
 

+ 75 - 0
Makefile

@@ -0,0 +1,75 @@
+# Canonical task runner. Mirrors .github/workflows/ci.yml so `make verify`
+# reproduces the PR gate locally. Run `make help` for the list.
+
+SHELL := bash
+GO_PKGS = $(shell go list ./... | grep -v '/frontend/node_modules/')
+FRONTEND = frontend
+
+.DEFAULT_GOAL := help
+
+.PHONY: help
+help: ## Show this help
+	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
+		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-14s %s\n", $$1, $$2}'
+
+# go:embed of internal/web/dist needs the dir to exist even when the
+# frontend bundle has not been built. CI stubs it the same way.
+.PHONY: dist-stub
+dist-stub:
+	@mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
+
+.PHONY: gen
+gen: ## Regenerate Zod schemas + OpenAPI from Go sources
+	cd $(FRONTEND) && npm run gen
+
+.PHONY: gen-check
+gen-check: gen ## Fail if generated files are stale
+	git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
+
+.PHONY: lint-go
+lint-go: dist-stub ## golangci-lint on Go sources
+	golangci-lint run
+
+.PHONY: lint-fe
+lint-fe: ## ESLint on frontend sources
+	cd $(FRONTEND) && npm run lint
+
+.PHONY: lint
+lint: lint-go lint-fe ## All linters
+
+.PHONY: typecheck
+typecheck: ## tsc --noEmit
+	cd $(FRONTEND) && npm run typecheck
+
+.PHONY: test-go
+test-go: dist-stub ## Go tests (shuffle, no cache)
+	go test -shuffle=on -count=1 $(GO_PKGS)
+
+.PHONY: race
+race: dist-stub ## Go tests with the race detector (needs a C compiler)
+	go test -race -shuffle=on -count=1 $(GO_PKGS)
+
+.PHONY: test-fe
+test-fe: ## Frontend tests (vitest)
+	cd $(FRONTEND) && npm test
+
+.PHONY: test
+test: test-go test-fe ## All tests
+
+.PHONY: vulncheck
+vulncheck: dist-stub ## govulncheck
+	go run golang.org/x/vuln/cmd/govulncheck@latest ./...
+
+.PHONY: build-fe
+build-fe: ## Build the Vite bundles into internal/web/dist
+	cd $(FRONTEND) && npm run build
+
+.PHONY: build
+build: build-fe ## Build the frontend then the Go binary
+	go build ./...
+
+# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
+# both test suites, and a full build.
+.PHONY: verify
+verify: gen-check lint typecheck test build ## Full local gate (mirrors CI)
+	@echo "verify: OK"

+ 60 - 0
frontend/CLAUDE.md

@@ -0,0 +1,60 @@
+# frontend/CLAUDE.md
+
+Frontend agent guide. Full detail: `frontend/README.md` and the root
+`CONTRIBUTING.md` ("Working on the frontend"). This is the short version.
+
+## What this is
+React 19 + Ant Design 6 + Vite 8 + TypeScript. The Vite config is
+`vite.config.js` (plain JS). Three bundles, each emitted into
+`internal/web/dist/` and embedded into the Go binary:
+- `index.html` — admin panel SPA (entry `src/main.tsx`; react-router under
+  `/panel`, lazy routes).
+- `login.html` — login + 2FA (`src/entries/login.tsx`).
+- `subpage.html` — public subscription viewer (`src/entries/subpage.tsx`).
+The `@` import alias maps to `src/`.
+
+## Data flow
+- Server state via TanStack Query (`src/api/`, keys in `src/api/queryKeys.ts`);
+  invalidate on mutation. WebSocket pushes feed the cache
+  (`src/api/websocketBridge.ts`).
+- Local UI state in the page (`useState`); shared concerns via `src/hooks/`.
+  Extend an existing hook before adding a global.
+- Zod (`src/schemas/`) is the single source of truth for the xray config model.
+  Infer types with `z.infer`. Go-side types are mirrored into `src/generated/`
+  by `npm run gen:zod` (`go run ./tools/openapigen`) — do not hand-edit that
+  folder (every file is marked `DO NOT EDIT`).
+- xray domain logic (links, defaults, form<->wire adapters) is pure functions in
+  `src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
+
+## Rules
+- Ant Design 6 only; no Tailwind/shadcn (a migration was rolled back).
+- Function components + hooks only; no class components.
+- No `//` line comments in committed TS/TSX. HTML comments are fine.
+- TS strict; `no-explicit-any` is an error. Validate form fields with
+  `antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline
+  `z.string()`.
+- New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`,
+  then `npm run gen`.
+- i18n strings live in `internal/web/translation/<locale>.json`, NOT under
+  `frontend/`, and are shared with the Go backend. A new English key must be
+  added to every locale. Interpolation here uses single braces `{var}`, not the
+  i18next default `{{var}}`.
+- Persian/Arabic (RTL) users are first-class — isolate code identifiers on their
+  own line when writing Persian text in labels/toasts.
+- Vite is pinned to an exact version (no `^`) — bump deliberately, then verify
+  `npm run dev` AND `npm run build`.
+
+## Adding a panel route
+1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
+2. Register in `src/routes.tsx` under `/panel` (lazy import).
+3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
+Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
+`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
+
+## Commands
+- `npm run dev` (HMR on :5173, proxies to the Go panel on :2053 — start Go first).
+- `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
+- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
+  (`build-openapi.mjs` → `public/openapi.json`).
+- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
+  CLAUDE.md) before checking the panel.

+ 86 - 0
frontend/public/openapi.json

@@ -2715,6 +2715,43 @@
         }
       }
     },
+    "/panel/api/inbounds/allLinks": {
+      "get": {
+        "tags": [
+          "Inbounds"
+        ],
+        "summary": "Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s \"Export all inbound links\" action.",
+        "operationId": "get_panel_api_inbounds_allLinks",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    "vless://uuid@host:443?security=reality&...#Germany-alice",
+                    "vmess://eyJ2IjoyLC..."
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/inbounds/get/{id}": {
       "get": {
         "tags": [
@@ -6582,6 +6619,55 @@
         }
       }
     },
+    "/panel/api/clients/groups/resetTraffic": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Reset only the group-level traffic counter shown on the groups page. Snapshots the current up/down sum of the group's members as a baseline so the group total reads zero, while leaving each client's own counters (and their quotas) untouched. No Xray restart is triggered. Creates the client_groups row if the group exists only as a derived label.",
+        "operationId": "post_panel_api_clients_groups_resetTraffic",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "name": "customer-a"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "name": "customer-a"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/resetTraffic/{email}": {
       "post": {
         "tags": [

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

@@ -126,6 +126,14 @@ export const sections: readonly Section[] = [
         responseSchema: 'InboundOption',
         responseSchemaArray: true,
       },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/allLinks',
+        summary:
+          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s "Export all inbound links" action.',
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#Germany-alice",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/inbounds/get/:id',
@@ -776,6 +784,13 @@ export const sections: readonly Section[] = [
         body: '{\n  "name": "customer-a"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "affected": 5\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/groups/resetTraffic',
+        summary: 'Reset only the group-level traffic counter shown on the groups page. Snapshots the current up/down sum of the group\'s members as a baseline so the group total reads zero, while leaving each client\'s own counters (and their quotas) untouched. No Xray restart is triggered. Creates the client_groups row if the group exists only as a derived label.',
+        body: '{\n  "name": "customer-a"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "name": "customer-a"\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/resetTraffic/:email',

+ 6 - 9
frontend/src/pages/groups/GroupsPage.tsx

@@ -126,9 +126,9 @@ export default function GroupsPage() {
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
-  const bulkResetMut = useMutation({
-    mutationFn: (body: { emails: string[] }) =>
-      HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS),
+  const groupResetMut = useMutation({
+    mutationFn: (body: { name: string }) =>
+      HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
@@ -321,17 +321,14 @@ export default function GroupsPage() {
     }
     modal.confirm({
       title: t('pages.groups.resetConfirmTitle', { name: g.name }),
-      content: t('pages.groups.resetConfirmContent', { count: g.clientCount }),
+      content: t('pages.groups.resetConfirmContent'),
       okText: t('reset'),
       okType: 'danger',
       cancelText: t('cancel'),
       onOk: async () => {
-        const emails = await fetchEmailsForGroup(g.name);
-        if (emails.length === 0) return;
-        const msg = await bulkResetMut.mutateAsync({ emails });
+        const msg = await groupResetMut.mutateAsync({ name: g.name });
         if (msg?.success) {
-          const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
-          messageApi.success(t('pages.groups.resetSuccess', { count: affected }));
+          messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
         }
       },
     });

+ 4 - 15
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -292,21 +292,10 @@ export default function InboundsPage() {
   }, [subSettings, openText, t]);
 
   const exportAllLinks = useCallback(async () => {
-    const hydrated = await Promise.all(
-      dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
-    );
-    const out: string[] = [];
-    for (const ib of hydrated) {
-      const projected = checkFallback(ib);
-      out.push(genInboundLinks({
-        inbound: inboundFromDb(projected),
-        remark: projected.remark,
-        hostOverride: hostOverrideFor(ib),
-        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
-      }));
-    }
-    openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
-  }, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
+    const msg = await HttpUtil.get('/panel/api/inbounds/allLinks');
+    const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : [];
+    openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: links.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
+  }, [openText, t]);
 
   const exportAllSubs = useCallback(async () => {
     const hydrated = await Promise.all(

+ 26 - 12
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -26,14 +26,23 @@ export function collectSelectors(list: BalancerObject[]): string[] {
 }
 
 // syncObservatories keeps the (burst)observatory sections aligned with the
-// balancer strategies that actually require them. Observatories have no
-// runtime reload API in xray-core, so any change here forces a full process
-// restart — that's why random/roundRobin balancers, which work fine without
-// an observer, never CREATE one: a plain balancer add/edit then stays a
-// routing-only change and applies live through the core API. An already
-// existing burstObservatory is still kept in sync for them (alive-only
-// filtering keeps working for setups that had it), it's just never the
-// reason a new one appears.
+// balancer strategies that actually require them. Observatories have no runtime
+// reload API in xray-core, so creating OR removing one forces a full process
+// restart — that's why an observer-less balancer never gets one and stays a
+// live, routing-only change applied through the core API.
+//
+// xray-core binds the Observatory feature to a Random/RoundRobinStrategy only
+// when its fallbackTag is set (issue #5605): with a fallbackTag the strategy
+// calls RequireFeatures(Observatory) and the core aborts startup with "not all
+// dependencies are resolved" if none exists; without a fallbackTag it never even
+// consults an observatory. leastLoad always needs the burst observer, leastPing
+// the regular one.
+//
+// So each observer lives exactly as long as something requires it, and is
+// dropped the moment nothing does — clearing the last fallbackTag (or deleting
+// the last leastLoad) removes the burst observer again. A no-fallback balancer's
+// selector is still probed while the observer exists for another reason, but
+// never keeps it alive on its own.
 export function syncObservatories(t: XraySettingsValue) {
   const balancers = (t.routing?.balancers || []) as BalancerObject[];
 
@@ -45,15 +54,20 @@ export function syncObservatories(t: XraySettingsValue) {
     delete t.observatory;
   }
 
-  const required = balancers.filter((b) => b.strategy?.type === 'leastLoad');
+  const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0;
+  const required = balancers.filter((b) => {
+    const type = b.strategy?.type || 'random';
+    if (type === 'leastLoad') return true;
+    return (type === 'random' || type === 'roundRobin') && hasFallback(b);
+  });
   const optional = balancers.filter((b) => {
     const type = b.strategy?.type || 'random';
-    return type === 'random' || type === 'roundRobin';
+    return (type === 'random' || type === 'roundRobin') && !hasFallback(b);
   });
-  if (required.length > 0 || (optional.length > 0 && t.burstObservatory)) {
+  if (required.length > 0) {
     if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
     (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]);
-  } else if (required.length === 0 && optional.length === 0) {
+  } else {
     delete t.burstObservatory;
   }
 }

+ 1 - 1
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -55,7 +55,7 @@ const initialForm = (): FormState => ({
   balancerTag: '',
 });
 
-const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
+const NETWORKS = ['', 'tcp', 'udp', 'tcp,udp'];
 const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
 
 function csv(value: string): string[] {

+ 1 - 1
frontend/src/pages/xray/routing/helpers.ts

@@ -83,7 +83,7 @@ export function ruleCriteriaChips(rule: RuleRow) {
   if (rule.port) chips.push({ label: 'Port', value: rule.port });
   if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
   if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
-  if (rule.network) chips.push({ label: 'L4', value: rule.network });
+  if (rule.network) chips.push({ label: 'L4', value: rule.network.toUpperCase() });
   if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
   if (rule.user) chips.push({ label: 'User', value: rule.user });
   if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });

+ 1 - 1
frontend/src/pages/xray/routing/useRoutingColumns.tsx

@@ -133,7 +133,7 @@ export function useRoutingColumns({
         key: 'network',
         render: (_v, record) => (
           <div className="criterion-flow">
-            {record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
+            {record.network && <CriterionRow label="L4" value={record.network.toUpperCase()} title={`L4: ${record.network.toUpperCase()}`} />}
             {record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
             {record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
             {!record.network && !record.protocol && !record.attrs && <span className="criterion-empty">—</span>}

+ 56 - 2
frontend/src/test/balancer-observatory-sync.test.ts

@@ -10,7 +10,8 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
 // Observatory sections have no reload API in xray-core, so creating one turns
 // a balancer save from a live (hot-applied) routing change into a full
 // restart. These tests pin the rule: only strategies that genuinely need an
-// observer may create one.
+// observer may create one — which, for random/roundRobin, means a fallbackTag
+// is set (xray-core then requires the Observatory feature; see #5605).
 describe('syncObservatories', () => {
   it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
     const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
@@ -19,12 +20,65 @@ describe('syncObservatories', () => {
     expect(t.observatory).toBeUndefined();
   });
 
-  it('does not create burstObservatory for roundRobin', () => {
+  it('does not create burstObservatory for roundRobin without fallback', () => {
     const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] });
     syncObservatories(t);
     expect(t.burstObservatory).toBeUndefined();
   });
 
+  it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => {
+    const t = tpl({ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }] });
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeDefined();
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['opera-proxy']);
+  });
+
+  it('creates burstObservatory for roundRobin with a fallbackTag', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }] });
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeDefined();
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
+  });
+
+  it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] });
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeUndefined();
+  });
+
+  it('removes burstObservatory when a random balancer drops its fallbackTag', () => {
+    const t = tpl(
+      { balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] },
+      { burstObservatory: { subjectSelector: ['opera-proxy'] } },
+    );
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeUndefined();
+  });
+
+  it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => {
+    const t = tpl(
+      { balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] },
+      { burstObservatory: { subjectSelector: ['a'] } },
+    );
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeUndefined();
+  });
+
+  it('keeps burstObservatory while another fallback balancer still needs it', () => {
+    const t = tpl(
+      {
+        balancers: [
+          { tag: 'b1', selector: ['a'] },
+          { tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
+        ],
+      },
+      { burstObservatory: { subjectSelector: ['a', 'b'] } },
+    );
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeDefined();
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']);
+  });
+
   it('creates burstObservatory for leastLoad (required by the strategy)', () => {
     const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
     syncObservatories(t);

+ 2 - 1
internal/database/api_token_timestamp_test.go

@@ -3,10 +3,11 @@ package database
 import (
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 	"gorm.io/gorm/logger"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestNormalizeApiTokenCreatedAtSeconds(t *testing.T) {

+ 42 - 26
internal/database/db.go

@@ -4,6 +4,7 @@ package database
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -43,7 +44,7 @@ func IsPostgres() bool {
 	if db == nil {
 		return config.GetDBKind() == "postgres"
 	}
-	return db.Dialector.Name() == "postgres"
+	return db.Name() == "postgres"
 }
 
 // Dialect returns the active GORM dialect name, or "" if the DB is not open.
@@ -51,7 +52,7 @@ func Dialect() string {
 	if db == nil {
 		return ""
 	}
-	return db.Dialector.Name()
+	return db.Name()
 }
 
 const (
@@ -183,32 +184,48 @@ func seedHostsFromExternalProxy() error {
 
 	return db.Transaction(func(tx *gorm.DB) error {
 		for _, inbound := range inbounds {
-			if strings.TrimSpace(inbound.StreamSettings) == "" {
-				continue
-			}
-			var stream map[string]any
-			if err := json.Unmarshal([]byte(inbound.StreamSettings), &stream); err != nil {
-				log.Printf("HostsFromExternalProxy: skip inbound %d (invalid stream json): %v", inbound.Id, err)
-				continue
-			}
-			eps, ok := stream["externalProxy"].([]any)
-			if !ok || len(eps) == 0 {
-				continue
-			}
-			for i, raw := range eps {
-				ep, ok := raw.(map[string]any)
-				if !ok {
-					continue
-				}
-				if err := tx.Create(externalProxyEntryToHost(inbound.Id, i, ep)).Error; err != nil {
-					return err
-				}
+			if _, err := CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
+				return err
 			}
 		}
 		return tx.Create(&model.HistoryOfSeeders{SeederName: "HostsFromExternalProxy"}).Error
 	})
 }
 
+// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array
+// and inserts one Host row per entry on tx, returning the number of rows created.
+// It is the shared core of both the one-time seedHostsFromExternalProxy startup
+// migration and the inbound-import path: an inbound exported from a build that
+// predated the hosts table carries its external proxies inline in
+// streamSettings.externalProxy, and the startup migration is gated off after its
+// first run, so a freshly imported inbound must be converted here instead. Blank
+// or malformed streamSettings, or one without externalProxy entries, is a no-op.
+func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) {
+	if strings.TrimSpace(streamSettings) == "" {
+		return 0, nil
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return 0, nil
+	}
+	eps, ok := stream["externalProxy"].([]any)
+	if !ok || len(eps) == 0 {
+		return 0, nil
+	}
+	created := 0
+	for i, raw := range eps {
+		ep, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		if err := tx.Create(externalProxyEntryToHost(inboundId, i, ep)).Error; err != nil {
+			return created, err
+		}
+		created++
+	}
+	return created, nil
+}
+
 // externalProxyEntryToHost maps one legacy externalProxy entry onto a Host.
 // forceTls (same|tls|none) maps straight to Security; an unknown value falls back
 // to "same" (inherit). An empty remark gets a stable generated label so the row
@@ -347,7 +364,6 @@ func initUser() error {
 	}
 	if empty {
 		hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
-
 		if err != nil {
 			log.Printf("Error hashing default password: %v", err)
 			return err
@@ -564,7 +580,7 @@ func fail2banCanEnforce() bool {
 	if runtime.GOOS == "windows" {
 		return false
 	}
-	return exec.Command("fail2ban-client", "-h").Run() == nil
+	return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil
 }
 
 // clearLegacyProxySettings drops the deprecated panelProxy/tgBotProxy rows so a
@@ -1022,7 +1038,7 @@ func InitDB(dbPath string) error {
 		}
 	default:
 		dir := path.Dir(dbPath)
-		if err = os.MkdirAll(dir, 0755); err != nil {
+		if err = os.MkdirAll(dir, 0o755); err != nil {
 			return err
 		}
 		// Keep journal_mode=DELETE so the DB stays a single file (no -wal/-shm
@@ -1049,7 +1065,7 @@ func InitDB(dbPath string) error {
 			"PRAGMA temp_store=MEMORY",
 		}
 		for _, p := range pragmas {
-			if _, err := sqlDB.Exec(p); err != nil {
+			if _, err := sqlDB.ExecContext(context.Background(), p); err != nil {
 				return err
 			}
 		}

+ 8 - 11
internal/database/dump_sqlite.go

@@ -1,6 +1,7 @@
 package database
 
 import (
+	"context"
 	"database/sql"
 	"fmt"
 	"os"
@@ -50,23 +51,21 @@ func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
 	// Tables in creation order, each followed by its data.
 	type object struct{ name, ddl string }
 	var tables []object
-	rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
+	rows, err := sqlDB.QueryContext(context.Background(), `SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
 	if err != nil {
 		return nil, err
 	}
+	defer rows.Close()
 	for rows.Next() {
 		var o object
 		if err := rows.Scan(&o.name, &o.ddl); err != nil {
-			rows.Close()
 			return nil, err
 		}
 		tables = append(tables, o)
 	}
 	if err := rows.Err(); err != nil {
-		rows.Close()
 		return nil, err
 	}
-	rows.Close()
 
 	for _, t := range tables {
 		b.WriteString(t.ddl)
@@ -85,24 +84,22 @@ func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
 	}
 
 	// Indexes, triggers and views after the data is in place.
-	rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
+	rows2, err := sqlDB.QueryContext(context.Background(), `SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
 	if err != nil {
 		return nil, err
 	}
+	defer rows2.Close()
 	for rows2.Next() {
 		var ddl string
 		if err := rows2.Scan(&ddl); err != nil {
-			rows2.Close()
 			return nil, err
 		}
 		b.WriteString(ddl)
 		b.WriteString(";\n")
 	}
 	if err := rows2.Err(); err != nil {
-		rows2.Close()
 		return nil, err
 	}
-	rows2.Close()
 
 	b.WriteString("COMMIT;\n")
 
@@ -131,7 +128,7 @@ func RestoreSQLite(dumpPath, dstPath string) error {
 	}
 
 	// mattn/go-sqlite3 executes every statement in a multi-statement string.
-	if _, err := sqlDB.Exec(string(script)); err != nil {
+	if _, err := sqlDB.ExecContext(context.Background(), string(script)); err != nil {
 		sqlDB.Close()
 		os.Remove(dstPath)
 		return fmt.Errorf("restore failed: %w", err)
@@ -141,7 +138,7 @@ func RestoreSQLite(dumpPath, dstPath string) error {
 
 // dumpTableData appends one INSERT statement per row of table to b.
 func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
-	rows, err := db.Query(`SELECT * FROM "` + table + `"`)
+	rows, err := db.QueryContext(context.Background(), `SELECT * FROM "`+table+`"`)
 	if err != nil {
 		return err
 	}
@@ -213,6 +210,6 @@ func quoteSQLiteText(s string) string {
 
 func sqliteTableExists(db *sql.DB, name string) bool {
 	var found string
-	err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
+	err := db.QueryRowContext(context.Background(), `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
 	return err == nil
 }

+ 2 - 2
internal/database/migrate_data.go

@@ -67,7 +67,7 @@ func MigrateData(srcPath, dstDSN string) error {
 		return errors.New("destination DSN is required")
 	}
 
-	if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
+	if err := os.MkdirAll(path.Dir(srcPath), 0o755); err != nil {
 		return err
 	}
 
@@ -144,7 +144,7 @@ func ExportPostgresToSQLite(srcDSN, dstPath string) error {
 	if srcDSN == "" {
 		return errors.New("source DSN is required")
 	}
-	if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
+	if err := os.MkdirAll(path.Dir(dstPath), 0o755); err != nil {
 		return err
 	}
 	// Start from an empty file so AutoMigrate creates the canonical schema.

+ 2 - 0
internal/database/model/model.go

@@ -639,6 +639,8 @@ func (ClientRecord) TableName() string { return "clients" }
 type ClientGroup struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
+	ResetUp   int64  `json:"resetUp" gorm:"column:reset_up;default:0"`
+	ResetDown int64  `json:"resetDown" gorm:"column:reset_down;default:0"`
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 	UpdatedAt int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 }

+ 2 - 1
internal/eventbus/bus_test.go

@@ -6,8 +6,9 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 func TestMain(m *testing.M) {

+ 2 - 1
internal/logger/logger.go

@@ -10,9 +10,10 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/op/go-logging"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+
 	"gopkg.in/natefinch/lumberjack.v2"
 )
 

+ 11 - 6
internal/mtproto/manager.go

@@ -2,6 +2,7 @@ package mtproto
 
 import (
 	"bufio"
+	"context"
 	"encoding/json"
 	"fmt"
 	"net"
@@ -181,7 +182,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
 			cur.tag = inst.Tag
 			return nil
 		}
-		cur.proc.Stop()
+		_ = cur.proc.Stop()
 		delete(m.procs, inst.Id)
 	}
 	metricsPort, err := FreeLocalPort()
@@ -211,7 +212,7 @@ func (m *Manager) Remove(id int) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	if cur, ok := m.procs[id]; ok {
-		cur.proc.Stop()
+		_ = cur.proc.Stop()
 		delete(m.procs, id)
 		_ = os.Remove(configPathForID(id))
 		logger.Infof("mtproto: stopped mtg for inbound %d", id)
@@ -231,7 +232,7 @@ func (m *Manager) Reconcile(desired []Instance) {
 	}
 	for id, cur := range m.procs {
 		if _, ok := want[id]; !ok {
-			cur.proc.Stop()
+			_ = cur.proc.Stop()
 			delete(m.procs, id)
 			_ = os.Remove(configPathForID(id))
 		}
@@ -323,7 +324,7 @@ func (m *Manager) CollectTraffic() []Traffic {
 // for mtg's metrics endpoint and to allocate the per-inbound SOCKS egress
 // bridge port persisted into mtproto inbound settings.
 func FreeLocalPort() (int, error) {
-	l, err := net.Listen("tcp", "127.0.0.1:0")
+	l, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
 	if err != nil {
 		return 0, err
 	}
@@ -383,7 +384,11 @@ func writeConfig(path string, inst Instance, metricsPort int) error {
 // Best-effort: an unreachable endpoint or unrecognised format yields ok=false.
 func scrapeTraffic(port int) (up int64, down int64, ok bool) {
 	client := http.Client{Timeout: 3 * time.Second}
-	resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port))
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/metrics", port), nil)
+	if reqErr != nil {
+		return 0, 0, false
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return 0, 0, false
 	}
@@ -418,7 +423,7 @@ func scrapeTraffic(port int) (up int64, down int64, ok bool) {
 
 func parseMetricLine(line string) (name string, labels map[string]string, value float64, err error) {
 	labels = map[string]string{}
-	rest := line
+	var rest string
 	if brace := strings.IndexByte(line, '{'); brace >= 0 {
 		name = line[:brace]
 		end := strings.IndexByte(line, '}')

+ 2 - 1
internal/mtproto/process.go

@@ -5,6 +5,7 @@
 package mtproto
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"os"
@@ -154,7 +155,7 @@ func (p *Process) Start() error {
 	if p.IsRunning() {
 		return errors.New("mtg is already running")
 	}
-	cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
+	cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
 	cmd.Stdout = p.logWriter
 	cmd.Stderr = p.logWriter
 	p.cmd = cmd

+ 4 - 3
internal/mtproto/process_windows.go

@@ -7,8 +7,9 @@ import (
 	"sync"
 	"unsafe"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"golang.org/x/sys/windows"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 var (
@@ -36,7 +37,7 @@ func ensureKillOnExitJob() (windows.Handle, error) {
 			uint32(unsafe.Sizeof(info)),
 		)
 		if err != nil {
-			windows.CloseHandle(h)
+			_ = windows.CloseHandle(h)
 			killOnExitJobErr = err
 			return
 		}
@@ -59,7 +60,7 @@ func attachChildLifetime(cmd *exec.Cmd) {
 		logger.Warningf("mtproto: OpenProcess for job attach failed: %v", err)
 		return
 	}
-	defer windows.CloseHandle(h)
+	defer func() { _ = windows.CloseHandle(h) }()
 	if err := windows.AssignProcessToJobObject(job, h); err != nil {
 		logger.Warningf("mtproto: AssignProcessToJobObject failed: %v", err)
 	}

+ 3 - 3
internal/sub/clash_service.go

@@ -241,7 +241,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		proxy["type"] = "vless"
 		proxy["uuid"] = client.ID
 		var inboundSettings map[string]any
-		json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 		streamSecurity, _ := stream["security"].(string)
 		if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
 			proxy["flow"] = client.Flow
@@ -259,7 +259,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		proxy["type"] = "ss"
 		proxy["password"] = client.Password
 		var inboundSettings map[string]any
-		json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 		method, _ := inboundSettings["method"].(string)
 		if method == "" {
 			return nil
@@ -655,7 +655,7 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
 
 func (s *SubClashService) streamData(stream string) map[string]any {
 	var streamSettings map[string]any
-	json.Unmarshal([]byte(stream), &streamSettings)
+	_ = json.Unmarshal([]byte(stream), &streamSettings)
 	security, _ := streamSettings["security"].(string)
 	switch security {
 	case "tls":

+ 3 - 2
internal/sub/controller.go

@@ -16,6 +16,7 @@ import (
 	"time"
 
 	"github.com/gin-gonic/gin"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
@@ -417,7 +418,7 @@ func (a *SUBController) ApplyCommonHeaders(
 	c.Writer.Header().Set("Subscription-Userinfo", header)
 	c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
 
-	//Basics
+	// Basics
 	if profileTitle != "" {
 		c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
 	}
@@ -431,7 +432,7 @@ func (a *SUBController) ApplyCommonHeaders(
 		c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
 	}
 
-	//Advanced (Happ)
+	// Advanced (Happ)
 	c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
 	if profileRoutingRules != "" {
 		c.Writer.Header().Set("Routing", profileRoutingRules)

+ 43 - 0
internal/sub/export_all_links_test.go

@@ -0,0 +1,43 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// inboundLinks (the "Export all inbound links" path) must render the remark
+// template's whole Client token group per client, name-only — the same engine
+// the client/QR pages use.
+func TestInboundLinks_RemarkTemplateClientTokens(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	settings := `{"clients":[{"id":"11111111-2222-4333-8444-000000000001","email":"john@e","subId":"subABC","comment":"vip","tgId":777,"enable":true}],"decryption":"none"}`
+	ib := &model.Inbound{
+		UserId: 1, Tag: "t", Enable: true, Listen: "203.0.113.5", Port: 4431,
+		Protocol: model.VLESS, Remark: "Germany", Settings: settings,
+		StreamSettings: `{"network":"ws","security":"tls","wsSettings":{"path":"/","host":""},"tlsSettings":{"serverName":"sni"}}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+
+	svc := NewSubService("{{INBOUND}}-{{EMAIL}}-{{COMMENT}}-{{SUB_ID}}-{{TELEGRAM_ID}}-{{SHORT_ID}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	svc.PrepareForRequest("req.example.com")
+	links := svc.inboundLinks(ib)
+
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	frag := links[0]
+	for _, want := range []string{"Germany-john", "vip", "subABC", "777", "11111111"} {
+		if !strings.Contains(frag, want) {
+			t.Fatalf("remark missing client token %q: %s", want, frag)
+		}
+	}
+	if strings.Contains(frag, "GB") || strings.ContainsRune(frag, '⏳') {
+		t.Fatalf("display mode must drop the traffic/expiry segments: %s", frag)
+	}
+}

+ 2 - 1
internal/sub/external_subscription.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"context"
 	"encoding/base64"
 	"io"
 	"net/http"
@@ -64,7 +65,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
 }
 
 func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
-	req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
 	if err != nil {
 		return nil, err
 	}

+ 2 - 1
internal/sub/external_subscription_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"errors"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -14,7 +15,7 @@ func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
 	defer srv.Close()
 
 	links, err := doFetchSubscriptionLinks(srv.URL)
-	if err != errSubscriptionBodyTooLarge {
+	if !errors.Is(err, errSubscriptionBodyTooLarge) {
 		t.Fatalf("err = %v, want errSubscriptionBodyTooLarge", err)
 	}
 	if links != nil {

+ 7 - 7
internal/sub/json_service.go

@@ -29,7 +29,7 @@ type SubJsonService struct {
 func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
 	var configJson map[string]any
 	var defaultOutbounds []json_util.RawMessage
-	json.Unmarshal([]byte(defaultJson), &configJson)
+	_ = json.Unmarshal([]byte(defaultJson), &configJson)
 	if outboundSlices, ok := configJson["outbounds"].([]any); ok {
 		for _, defaultOutbound := range outboundSlices {
 			jsonBytes, _ := json.Marshal(defaultOutbound)
@@ -41,7 +41,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		var newRules []any
 		routing, _ := configJson["routing"].(map[string]any)
 		defaultRules, _ := routing["rules"].([]any)
-		json.Unmarshal([]byte(rules), &newRules)
+		_ = json.Unmarshal([]byte(rules), &newRules)
 		defaultRules = append(newRules, defaultRules...)
 		routing["rules"] = defaultRules
 		configJson["routing"] = routing
@@ -234,7 +234,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 
 func (s *SubJsonService) streamData(stream string) map[string]any {
 	var streamSettings map[string]any
-	json.Unmarshal([]byte(stream), &streamSettings)
+	_ = json.Unmarshal([]byte(stream), &streamSettings)
 	security, _ := streamSettings["security"].(string)
 	switch security {
 	case "tls":
@@ -392,7 +392,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 
 	// Add encryption for VLESS outbound from inbound settings
 	var inboundSettings map[string]any
-	json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 	encryption, _ := inboundSettings["encryption"].(string)
 
 	settings := map[string]any{
@@ -423,7 +423,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 
 	if inbound.Protocol == model.Shadowsocks {
 		var inboundSettings map[string]any
-		json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 		method, _ := inboundSettings["method"].(string)
 		serverData[0].Method = method
 
@@ -474,7 +474,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 	}
 
 	var settings, stream map[string]any
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	version, _ := settings["version"].(float64)
 	outbound.Settings = map[string]any{
 		"version": int(version),
@@ -482,7 +482,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 		"port":    inbound.Port,
 	}
 
-	json.Unmarshal([]byte(inbound.StreamSettings), &stream)
+	_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
 	hyStream := stream["hysteriaSettings"].(map[string]any)
 	outHyStream := map[string]any{
 		"version": int(version),

+ 9 - 0
internal/sub/links.go

@@ -41,6 +41,15 @@ func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email
 	return splitLinkLines(svc.GetLink(inbound, email))
 }
 
+func (p *LinkProvider) LinksForInbounds(host string, inbounds []*model.Inbound) []string {
+	svc := p.build(host)
+	var out []string
+	for _, inbound := range inbounds {
+		out = append(out, svc.inboundLinks(inbound)...)
+	}
+	return out
+}
+
 func splitLinkLines(raw string) []string {
 	if raw == "" {
 		return nil

+ 10 - 1
internal/sub/remark_vars.go

@@ -484,6 +484,15 @@ var connectionTokens = map[string]bool{
 
 var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)
 
+// firstLinkOnlyBodyTokens are stripped from every subscription-body link after a
+// client's first one: the usage/info tokens plus the per-client EMAIL/USERNAME
+// identity. A client app needs the email once, so repeating it on every link of
+// the same subscription is noise — show it on the first link only, like traffic.
+var firstLinkOnlyBodyTokens = mergeTokenSets(usageInfoTokens, map[string]bool{
+	"EMAIL":    true,
+	"USERNAME": true,
+})
+
 func mergeTokenSets(sets ...map[string]bool) map[string]bool {
 	out := make(map[string]bool)
 	for _, set := range sets {
@@ -554,7 +563,7 @@ func (s *SubService) effectiveTemplate(email string) string {
 		s.usageShown = map[string]bool{}
 	}
 	if s.usageShown[email] {
-		return filterRemarkTemplate(translated, usageInfoTokens)
+		return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens)
 	}
 	s.usageShown[email] = true
 	return translated

+ 32 - 3
internal/sub/remark_vars_test.go

@@ -361,7 +361,7 @@ func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
 	}
 }
 
-func TestIdentityTokensEverywhere(t *testing.T) {
+func TestIdentityTokenBodyVsDisplay(t *testing.T) {
 	const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
 	inbound := &model.Inbound{
 		Remark:         "DE",
@@ -373,8 +373,8 @@ func TestIdentityTokensEverywhere(t *testing.T) {
 
 	body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
 	_ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
-	if second := body.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(second, "john@x") {
-		t.Fatalf("repeat body link %q must keep the identity token", second)
+	if second := body.genTemplatedRemark(inbound, client, "", "ws"); strings.Contains(second, "john@x") {
+		t.Fatalf("repeat body link %q must drop the identity token", second)
 	}
 
 	display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
@@ -610,3 +610,32 @@ func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
 		t.Fatalf("second link must not carry usage: %q", second)
 	}
 }
+
+func TestEmailOnFirstLinkOnly(t *testing.T) {
+	s := &SubService{
+		remarkTemplate:   "{{INBOUND}} {{EMAIL}}|📊{{TRAFFIC_LEFT}}",
+		subscriptionBody: true,
+		usageShown:       map[string]bool{},
+	}
+	inbound := &model.Inbound{
+		Remark: "DE",
+		ClientStats: []xray.ClientTraffic{{
+			Email:  "alice@x",
+			Enable: true,
+			Total:  100 * gb,
+		}},
+	}
+	client := model.Client{Email: "alice@x"}
+	first := s.genTemplatedRemark(inbound, client, "", "ws")
+	s.usageShown["alice@x"] = true
+	second := s.genTemplatedRemark(inbound, client, "", "ws")
+	if !strings.Contains(first, "alice@x") {
+		t.Fatalf("first link should carry email: %q", first)
+	}
+	if strings.Contains(second, "alice@x") {
+		t.Fatalf("second link must not carry email: %q", second)
+	}
+	if !strings.Contains(second, "DE") {
+		t.Fatalf("second link should still carry the inbound name: %q", second)
+	}
+}

+ 40 - 9
internal/sub/service.go

@@ -242,6 +242,37 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 	return result, emails, lastOnline, traffic, nil
 }
 
+// inboundLinks builds the share links for every distinct client of one inbound
+// the same way getSubs does — managed Host endpoints win over the plain link so
+// {{HOST}} and per-host variants render — but across all clients rather than a
+// single subId. Dedups duplicate client JSON entries by email (#5134). Backs the
+// panel's "Export all inbound links" so it matches the client/QR pages.
+func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
+	clients, err := s.inboundService.GetClients(inbound)
+	if err != nil {
+		return nil
+	}
+	s.projectThroughFallbackMaster(inbound)
+	hostEps := s.hostEndpoints(inbound, "raw")
+	var out []string
+	seen := make(map[string]struct{}, len(clients))
+	for _, client := range clients {
+		key := strings.ToLower(client.Email)
+		if _, dup := seen[key]; dup {
+			continue
+		}
+		seen[key] = struct{}{}
+		var link string
+		if len(hostEps) > 0 {
+			link = s.linkFromHosts(inbound, client, hostEps)
+		} else {
+			link = s.GetLink(inbound, client.Email)
+		}
+		out = append(out, splitLinkLines(link)...)
+	}
+	return out
+}
+
 // AggregateTrafficByEmails resolves traffic for every email in one
 // query and folds the rows into a single ClientTraffic + lastOnline.
 // xray.ClientTraffic.Email is globally unique, so a multi-inbound
@@ -422,12 +453,12 @@ func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
 // + ws/grpc/etc. settings) stays the child's.
 func mergeStreamFromMaster(childStream, masterStream string) string {
 	var stream map[string]any
-	json.Unmarshal([]byte(childStream), &stream)
+	_ = json.Unmarshal([]byte(childStream), &stream)
 	if stream == nil {
 		stream = map[string]any{}
 	}
 	var mst map[string]any
-	json.Unmarshal([]byte(masterStream), &mst)
+	_ = json.Unmarshal([]byte(masterStream), &mst)
 	if mst == nil {
 		return childStream
 	}
@@ -480,7 +511,7 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
 		return ""
 	}
 	settings := map[string]any{}
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	secret, _ := settings["secret"].(string)
 	if secret == "" {
 		if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
@@ -586,7 +617,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 
 	// Add encryption parameter for VLESS from inbound settings
 	var settings map[string]any
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	if encryption, ok := settings["encryption"].(string); ok {
 		params["encryption"] = encryption
 	}
@@ -708,7 +739,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 	clients, _ := s.inboundService.GetClients(inbound)
 
 	var settings map[string]any
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	inboundPassword := settings["password"].(string)
 	method := settings["method"].(string)
 	clientIndex := findClientIndex(clients, email)
@@ -777,7 +808,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		return ""
 	}
 	var stream map[string]any
-	json.Unmarshal([]byte(inbound.StreamSettings), &stream)
+	_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
 	clients, _ := s.inboundService.GetClients(inbound)
 	clientIndex := -1
 	for i, client := range clients {
@@ -846,7 +877,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 	}
 
 	var settings map[string]any
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	version, _ := settings["version"].(float64)
 	protocol := "hysteria2"
 	if int(version) == 1 {
@@ -977,7 +1008,7 @@ func findClientIndex(clients []model.Client, email string) int {
 
 func unmarshalStreamSettings(streamSettings string) map[string]any {
 	var stream map[string]any
-	json.Unmarshal([]byte(streamSettings), &stream)
+	_ = json.Unmarshal([]byte(streamSettings), &stream)
 	return stream
 }
 
@@ -1285,7 +1316,7 @@ func buildVmessLink(obj map[string]any) string {
 func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any {
 	newObj := map[string]any{}
 	for key, value := range baseObj {
-		if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "pcs")) {
+		if newSecurity != "none" || (key != "alpn" && key != "sni" && key != "fp" && key != "pcs") {
 			newObj[key] = value
 		}
 	}

+ 3 - 3
internal/sub/sub.go

@@ -253,7 +253,7 @@ func (s *Server) Start() (err error) {
 	// This is an anonymous function, no function name
 	defer func() {
 		if err != nil {
-			s.Stop()
+			_ = s.Stop()
 		}
 	}()
 
@@ -288,7 +288,7 @@ func (s *Server) Start() (err error) {
 	}
 
 	listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
-	listener, err := net.Listen("tcp", listenAddr)
+	listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr)
 	if err != nil {
 		return err
 	}
@@ -323,7 +323,7 @@ func (s *Server) Start() (err error) {
 	}
 
 	go func() {
-		s.httpServer.Serve(listener)
+		_ = s.httpServer.Serve(listener)
 	}()
 
 	return nil

+ 1 - 1
internal/tunnelmonitor/monitor.go

@@ -189,7 +189,7 @@ func (m *Monitor) Step(ctx context.Context) (bool, error) {
 		}
 
 		if recErr := m.recover(ctx); recErr != nil {
-			return false, fmt.Errorf("recovery failed after probe error %v: %w", err, recErr)
+			return false, fmt.Errorf("recovery failed after probe error %w: %w", err, recErr)
 		}
 
 		m.lastRecovery = now

+ 2 - 1
internal/tunnelmonitor/monitor_test.go

@@ -9,8 +9,9 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 func TestMain(m *testing.M) {

+ 2 - 1
internal/util/common/gorecover_test.go

@@ -5,8 +5,9 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 func TestMain(m *testing.M) {

+ 1 - 1
internal/util/random/random_test.go

@@ -15,7 +15,7 @@ func TestSeq_LengthAndAlphabet(t *testing.T) {
 			isDigit := r >= '0' && r <= '9'
 			isLower := r >= 'a' && r <= 'z'
 			isUpper := r >= 'A' && r <= 'Z'
-			if !(isDigit || isLower || isUpper) {
+			if !isDigit && !isLower && !isUpper {
 				t.Fatalf("Seq(%d) byte %d = %q is not alphanumeric", n, i, r)
 			}
 		}

+ 2 - 1
internal/util/sys/sys_linux.go

@@ -4,6 +4,7 @@ package sys
 
 import (
 	"bufio"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -93,7 +94,7 @@ func CPUPercentRaw() (float64, error) {
 
 	rd := bufio.NewReader(f)
 	line, err := rd.ReadString('\n')
-	if err != nil && err != io.EOF {
+	if err != nil && !errors.Is(err, io.EOF) {
 		return 0, err
 	}
 	// Expect: cpu  user nice system idle iowait irq softirq steal guest guest_nice

+ 2 - 1
internal/util/sys/sys_windows.go

@@ -66,7 +66,8 @@ func CPUPercentRaw() (float64, error) {
 		uintptr(unsafe.Pointer(&userFT)),
 	)
 	if r1 == 0 {
-		if errno, _ := e1.(syscall.Errno); errno != 0 {
+		var errno syscall.Errno
+		if errors.As(e1, &errno) && errno != 0 {
 			return 0, errno
 		}
 		return 0, errors.New("GetSystemTimes failed")

+ 0 - 2
internal/web/controller/api.go

@@ -5,7 +5,6 @@ import (
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -22,7 +21,6 @@ type APIController struct {
 	hostController        *HostController
 	settingController     *SettingController
 	xraySettingController *XraySettingController
-	settingService        service.SettingService
 	userService           panel.UserService
 	apiTokenService       panel.ApiTokenService
 	Tgbot                 tgbot.Tgbot

+ 19 - 0
internal/web/controller/group.go

@@ -26,6 +26,7 @@ func (a *GroupController) initRouter(g *gin.RouterGroup) {
 	g.POST("/groups/create", a.create)
 	g.POST("/groups/rename", a.rename)
 	g.POST("/groups/delete", a.delete)
+	g.POST("/groups/resetTraffic", a.resetTraffic)
 	g.POST("/groups/bulkAdd", a.bulkAdd)
 	g.POST("/groups/bulkRemove", a.bulkRemove)
 }
@@ -108,6 +109,24 @@ func (a *GroupController) delete(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+type groupResetTrafficBody struct {
+	Name string `json:"name"`
+}
+
+func (a *GroupController) resetTraffic(c *gin.Context) {
+	var body groupResetTrafficBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.clientService.ResetGroupTraffic(body.Name); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"name": body.Name}, nil)
+	notifyClientsChanged()
+}
+
 type bulkAddToGroupRequest struct {
 	Emails []string `json:"emails"`
 	Group  string   `json:"group"`

+ 14 - 1
internal/web/controller/inbound.go

@@ -61,10 +61,10 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
 
 // initRouter initializes the routes for inbound-related operations.
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
-
 	g.GET("/list", a.getInbounds)
 	g.GET("/list/slim", a.getInboundsSlim)
 	g.GET("/options", a.getInboundOptions)
+	g.GET("/allLinks", a.getAllInboundLinks)
 	g.GET("/get/:id", a.getInbound)
 	g.GET("/:id/fallbacks", a.getFallbacks)
 
@@ -104,6 +104,19 @@ func (a *InboundController) getInboundsSlim(c *gin.Context) {
 	jsonObj(c, inbounds, nil)
 }
 
+// getAllInboundLinks returns every inbound's share links across all clients,
+// rendered through the same subscription engine the client pages use so the
+// remark template (name-only display part) is applied consistently.
+func (a *InboundController) getAllInboundLinks(c *gin.Context) {
+	user := session.GetLoginUser(c)
+	links, err := a.inboundService.GetAllInboundLinks(resolveHost(c), user.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
 // getInboundOptions returns a lightweight projection of the user's inbounds
 // (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
 // Avoids shipping per-client settings and traffic stats just to fill a dropdown.

+ 6 - 7
internal/web/controller/server.go

@@ -42,7 +42,6 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
 
 // initRouter sets up the routes for server status, Xray management, and utility endpoints.
 func (a *ServerController) initRouter(g *gin.RouterGroup) {
-
 	g.GET("/status", a.status)
 	g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
 	g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
@@ -89,7 +88,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 // the cross-service side effects (xrayMetrics sample + websocket broadcast).
 func (a *ServerController) startTask() {
 	c := global.GetWebServer().GetCron()
-	c.AddFunc("@every 2s", func() {
+	_, _ = c.AddFunc("@every 2s", func() {
 		status := a.serverService.RefreshStatus()
 		if status == nil {
 			return
@@ -97,7 +96,7 @@ func (a *ServerController) startTask() {
 		a.xrayMetricsService.Sample(time.Now())
 		websocket.BroadcastStatus(status)
 	})
-	c.AddFunc("@every 1m", func() {
+	_, _ = c.AddFunc("@every 1m", func() {
 		if err := service.PersistSystemMetrics(); err != nil {
 			logger.Warning("persist system metrics failed:", err)
 		}
@@ -327,13 +326,13 @@ func (a *ServerController) getDb(c *gin.Context) {
 
 	filename := a.serverService.BackupFilename(c.Request.Host)
 	if !filenameRegex.MatchString(filename) {
-		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
+		_ = c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		return
 	}
 
 	c.Header("Content-Type", "application/octet-stream")
 	c.Header("Content-Disposition", "attachment; filename="+filename)
-	c.Writer.Write(db)
+	_, _ = c.Writer.Write(db)
 }
 
 // getMigration downloads a cross-engine migration file: a .dump on SQLite or a
@@ -345,13 +344,13 @@ func (a *ServerController) getMigration(c *gin.Context) {
 		return
 	}
 	if !filenameRegex.MatchString(filename) {
-		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
+		_ = c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		return
 	}
 
 	c.Header("Content-Type", "application/octet-stream")
 	c.Header("Content-Disposition", "attachment; filename="+filename)
-	c.Writer.Write(data)
+	_, _ = c.Writer.Write(data)
 }
 
 // importDB imports a database file and restarts the Xray service.

+ 8 - 7
internal/web/job/check_client_ip_job.go

@@ -1,6 +1,7 @@
 package job
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"log"
@@ -127,7 +128,7 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 		}
 
 		settings := map[string][]model.Client{}
-		json.Unmarshal([]byte(inbound.Settings), &settings)
+		_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 		clients := settings["clients"]
 
 		for _, client := range clients {
@@ -189,7 +190,7 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
 
 		clientIpsRecord, err := j.getInboundClientIps(email)
 		if err != nil {
-			j.addInboundClientIps(email, ipsWithTime)
+			_ = j.addInboundClientIps(email, ipsWithTime)
 			continue
 		}
 
@@ -277,7 +278,7 @@ func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
 
 	cmd := "fail2ban-client"
 	args := []string{"-h"}
-	err := exec.Command(cmd, args...).Run()
+	err := exec.CommandContext(context.Background(), cmd, args...).Run()
 	return err == nil
 }
 
@@ -345,7 +346,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	}
 
 	settings := map[string][]model.Client{}
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	clients := settings["clients"]
 
 	// Find the client's IP limit
@@ -372,7 +373,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	// Parse old IPs from database
 	var oldIpsWithTime []IPWithTimestamp
 	if inboundClientIps.Ips != "" {
-		json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
+		_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
 	}
 
 	ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
@@ -393,7 +394,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	if len(bannedLive) > 0 {
 		shouldCleanLog = true
 
-		logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+		logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
 		if err != nil {
 			logger.Errorf("failed to open IP limit log file: %s", err)
 			return false
@@ -455,7 +456,7 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
 		if client.Email == clientEmail {
 			// Convert client to map for API
 			clientBytes, _ := json.Marshal(client)
-			json.Unmarshal(clientBytes, &clientConfig)
+			_ = json.Unmarshal(clientBytes, &clientConfig)
 			break
 		}
 	}

+ 2 - 1
internal/web/job/check_client_ip_job_integration_test.go

@@ -9,10 +9,11 @@ import (
 	"testing"
 	"time"
 
+	"github.com/op/go-logging"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/op/go-logging"
 )
 
 // 3x-ui logger must be initialised once before any code path that can

+ 1 - 4
internal/web/job/check_cpu_usage.go

@@ -4,15 +4,12 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/shirou/gopsutil/v4/cpu"
 )
 
 // CheckCpuJob monitors CPU usage and publishes events when threshold is exceeded.
-type CheckCpuJob struct {
-	settingService service.SettingService
-}
+type CheckCpuJob struct{}
 
 // NewCheckCpuJob creates a new CPU monitoring job instance.
 func NewCheckCpuJob() *CheckCpuJob {

+ 1 - 4
internal/web/job/check_memory_usage.go

@@ -2,15 +2,12 @@ package job
 
 import (
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/shirou/gopsutil/v4/mem"
 )
 
 // CheckMemJob monitors memory usage and publishes events when threshold is exceeded.
-type CheckMemJob struct {
-	settingService service.SettingService
-}
+type CheckMemJob struct{}
 
 // NewCheckMemJob creates a new memory monitoring job instance.
 func NewCheckMemJob() *CheckMemJob {

+ 4 - 4
internal/web/job/clear_logs_job.go

@@ -20,11 +20,11 @@ func NewClearLogsJob() *ClearLogsJob {
 // ensureFileExists creates the necessary directories and file if they don't exist
 func ensureFileExists(path string) error {
 	dir := filepath.Dir(path)
-	if err := os.MkdirAll(dir, 0755); err != nil {
+	if err := os.MkdirAll(dir, 0o755); err != nil {
 		return err
 	}
 
-	file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
+	file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
 	if err != nil {
 		return err
 	}
@@ -48,13 +48,13 @@ func (j *ClearLogsJob) Run() {
 	for i := range len(logFiles) {
 		if i > 0 {
 			// Copy to previous logs
-			logFilePrev, err := os.OpenFile(logFilesPrev[i-1], os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
+			logFilePrev, err := os.OpenFile(logFilesPrev[i-1], os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
 			if err != nil {
 				logger.Warning("Failed to open previous log file for writing:", logFilesPrev[i-1], "-", err)
 				continue
 			}
 
-			logFile, err := os.OpenFile(logFiles[i], os.O_RDONLY, 0644)
+			logFile, err := os.OpenFile(logFiles[i], os.O_RDONLY, 0o644)
 			if err != nil {
 				logger.Warning("Failed to open current log file for reading:", logFiles[i], "-", err)
 				logFilePrev.Close()

+ 2 - 2
internal/web/job/clear_logs_job_test.go

@@ -19,14 +19,14 @@ func writeAccessLogConfig(t *testing.T, accessPath string) {
 	if err != nil {
 		t.Fatalf("marshal xray config: %v", err)
 	}
-	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
+	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0o644); err != nil {
 		t.Fatalf("write xray config: %v", err)
 	}
 }
 
 func TestWipeAccessLog_TruncatesEnabledLog(t *testing.T) {
 	accessLog := filepath.Join(t.TempDir(), "access.log")
-	if err := os.WriteFile(accessLog, []byte("2026/06/23 12:00:00 from tcp:203.0.113.10:443 accepted\n"), 0644); err != nil {
+	if err := os.WriteFile(accessLog, []byte("2026/06/23 12:00:00 from tcp:203.0.113.10:443 accepted\n"), 0o644); err != nil {
 		t.Fatalf("seed access log: %v", err)
 	}
 	writeAccessLogConfig(t, accessLog)

+ 1 - 1
internal/web/job/xray_traffic_job.go

@@ -178,7 +178,7 @@ func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traf
 	defer fasthttp.ReleaseRequest(request)
 	request.Header.SetMethod("POST")
 	request.Header.SetContentType("application/json; charset=UTF-8")
-	request.SetBody([]byte(requestBody))
+	request.SetBody(requestBody)
 	request.SetRequestURI(informURL)
 	response := fasthttp.AcquireResponse()
 	defer fasthttp.ReleaseResponse(response)

+ 1 - 1
internal/web/locale/locale.go

@@ -56,7 +56,7 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
 
 // createTemplateData creates a template data map from parameters with optional separator.
 func createTemplateData(params []string, separator ...string) map[string]any {
-	var sep string = "=="
+	sep := "=="
 	if len(separator) > 0 {
 		sep = separator[0]
 	}

+ 1 - 1
internal/web/network/auto_https_conn.go

@@ -50,7 +50,7 @@ func (c *AutoHttpsConn) readRequest() bool {
 	resp.StatusCode = http.StatusTemporaryRedirect
 	location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI)
 	resp.Header.Set("Location", location)
-	resp.Write(c.Conn)
+	_ = resp.Write(c.Conn)
 	c.Close()
 	c.firstBuf = nil
 	return true

+ 30 - 0
internal/web/service/backup_filename_test.go

@@ -3,6 +3,7 @@ package service
 import (
 	"regexp"
 	"testing"
+	"time"
 )
 
 // getDb (controller) only accepts a Content-Disposition filename matching this
@@ -36,3 +37,32 @@ func TestSanitizeBackupHost(t *testing.T) {
 		})
 	}
 }
+
+// dateSuffixRegex narrows backupFilenameRegex to the exact _YYYY-MM-DD_HHMMSS shape.
+var dateSuffixRegex = regexp.MustCompile(`^_\d{4}-\d{2}-\d{2}_\d{6}$`)
+
+func TestBackupDateSuffix(t *testing.T) {
+	cases := []struct {
+		name string
+		now  time.Time
+		want string
+	}{
+		{"utc midnight", time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC), "_2026-06-27_000000"},
+		{"end of year", time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), "_2025-12-31_235959"},
+		{"single digit month/day padded", time.Date(2026, 1, 5, 9, 4, 0, 0, time.UTC), "_2026-01-05_090400"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := backupDateSuffix(tc.now)
+			if got != tc.want {
+				t.Errorf("backupDateSuffix(%v) = %q, want %q", tc.now, got, tc.want)
+			}
+			if !dateSuffixRegex.MatchString(got) {
+				t.Errorf("backupDateSuffix(%v) = %q, not a valid date suffix", tc.now, got)
+			}
+			if !backupFilenameRegex.MatchString(got) {
+				t.Errorf("backupDateSuffix(%v) = %q, not a valid download filename char", tc.now, got)
+			}
+		})
+	}
+}

+ 8 - 4
internal/web/service/client_apply_field_test.go

@@ -34,10 +34,14 @@ func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
 		return string(b)
 	}
 
-	first := &model.Inbound{Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS,
-		StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry)}
-	second := &model.Inbound{Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS,
-		StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry)}
+	first := &model.Inbound{
+		Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry),
+	}
+	second := &model.Inbound{
+		Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS,
+		StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry),
+	}
 	for _, ib := range []*model.Inbound{first, second} {
 		if err := db.Create(ib).Error; err != nil {
 			t.Fatalf("create inbound %s: %v", ib.Tag, err)

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

@@ -8,6 +8,7 @@ import (
 	"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"

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

@@ -40,8 +40,10 @@ func flowOf(t *testing.T, svc *ClientService, email string) string {
 	return rec.Flow
 }
 
-const realityStream = `{"network":"tcp","security":"reality"}`
-const wsStream = `{"network":"ws","security":"none"}`
+const (
+	realityStream = `{"network":"tcp","security":"reality"}`
+	wsStream      = `{"network":"ws","security":"none"}`
+)
 
 // TestBulkAdjust_FlowSetAndClear covers the happy path: a vision flow is applied
 // on an eligible VLESS inbound and later cleared with the "none" directive. Both

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

@@ -9,6 +9,7 @@ import (
 	"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/util/common"

+ 127 - 0
internal/web/service/client_group_reset_test.go

@@ -0,0 +1,127 @@
+package service
+
+import (
+	"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/xray"
+)
+
+func groupByName(t *testing.T, svc *ClientService, name string) GroupSummary {
+	t.Helper()
+	rows, err := svc.ListGroups()
+	if err != nil {
+		t.Fatalf("ListGroups: %v", err)
+	}
+	for _, g := range rows {
+		if g.Name == name {
+			return g
+		}
+	}
+	t.Fatalf("group %q not found in %v", name, rows)
+	return GroupSummary{}
+}
+
+func seedGroupedClient(t *testing.T, email, group string, up, down int64) {
+	t.Helper()
+	if err := database.GetDB().Create(&model.ClientRecord{Email: email, Enable: true, Group: group}).Error; err != nil {
+		t.Fatalf("seed client record %q: %v", email, err)
+	}
+	seedClientRow(t, email, 1, up, down, 0)
+}
+
+func TestResetGroupTraffic_ZeroesGroupButKeepsClients(t *testing.T) {
+	initTrafficTestDB(t)
+	svc := &ClientService{}
+
+	seedGroupedClient(t, "alice", "vip", 100, 200)
+	seedGroupedClient(t, "bob", "vip", 50, 50)
+
+	before := groupByName(t, svc, "vip")
+	if before.Up != 150 || before.Down != 250 || before.TrafficUsed != 400 || before.ClientCount != 2 {
+		t.Fatalf("before reset: got %+v, want up=150 down=250 used=400 count=2", before)
+	}
+
+	if err := svc.ResetGroupTraffic("vip"); err != nil {
+		t.Fatalf("ResetGroupTraffic: %v", err)
+	}
+
+	after := groupByName(t, svc, "vip")
+	if after.Up != 0 || after.Down != 0 || after.TrafficUsed != 0 {
+		t.Fatalf("after reset: got %+v, want up=0 down=0 used=0", after)
+	}
+	if after.ClientCount != 2 {
+		t.Fatalf("after reset: client count changed to %d, want 2", after.ClientCount)
+	}
+
+	var alice xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "alice").First(&alice).Error; err != nil {
+		t.Fatalf("load alice traffic: %v", err)
+	}
+	if alice.Up != 100 || alice.Down != 200 {
+		t.Fatalf("client counter modified by group reset: alice up=%d down=%d, want 100/200", alice.Up, alice.Down)
+	}
+}
+
+func TestResetGroupTraffic_NewTrafficAccumulatesAboveBaseline(t *testing.T) {
+	initTrafficTestDB(t)
+	svc := &ClientService{}
+
+	seedGroupedClient(t, "carol", "team", 100, 100)
+	if err := svc.ResetGroupTraffic("team"); err != nil {
+		t.Fatalf("ResetGroupTraffic: %v", err)
+	}
+	if g := groupByName(t, svc, "team"); g.Up != 0 || g.Down != 0 {
+		t.Fatalf("after reset: got %+v, want up=0 down=0", g)
+	}
+
+	if err := database.GetDB().Table("client_traffics").
+		Where("email = ?", "carol").
+		Updates(map[string]any{"up": 130, "down": 100}).Error; err != nil {
+		t.Fatalf("bump carol traffic: %v", err)
+	}
+
+	g := groupByName(t, svc, "team")
+	if g.Up != 30 || g.Down != 0 || g.TrafficUsed != 30 {
+		t.Fatalf("post-bump: got %+v, want up=30 down=0 used=30", g)
+	}
+}
+
+func TestResetGroupTraffic_CreatesRowForDerivedGroup(t *testing.T) {
+	initTrafficTestDB(t)
+	svc := &ClientService{}
+
+	seedGroupedClient(t, "dave", "adhoc", 70, 30)
+
+	var rows int64
+	if err := database.GetDB().Model(&model.ClientGroup{}).Where("name = ?", "adhoc").Count(&rows).Error; err != nil {
+		t.Fatalf("count client_groups: %v", err)
+	}
+	if rows != 0 {
+		t.Fatalf("precondition: derived group should have no client_groups row, got %d", rows)
+	}
+
+	if err := svc.ResetGroupTraffic("adhoc"); err != nil {
+		t.Fatalf("ResetGroupTraffic: %v", err)
+	}
+
+	var stored model.ClientGroup
+	if err := database.GetDB().Where("name = ?", "adhoc").First(&stored).Error; err != nil {
+		t.Fatalf("client_groups row not created: %v", err)
+	}
+	if stored.ResetUp != 70 || stored.ResetDown != 30 {
+		t.Fatalf("baseline not snapshotted: got up=%d down=%d, want 70/30", stored.ResetUp, stored.ResetDown)
+	}
+	if g := groupByName(t, svc, "adhoc"); g.Up != 0 || g.Down != 0 {
+		t.Fatalf("after reset: got %+v, want up=0 down=0", g)
+	}
+}
+
+func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
+	initTrafficTestDB(t)
+	svc := &ClientService{}
+	if err := svc.ResetGroupTraffic("   "); err == nil {
+		t.Fatal("ResetGroupTraffic(blank) = nil, want error")
+	}
+}

+ 45 - 6
internal/web/service/client_groups.go

@@ -36,21 +36,32 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 		return nil, err
 	}
 	type groupAgg struct {
-		count   int
-		traffic int64
-		up      int64
-		down    int64
+		count int
+		up    int64
+		down  int64
 	}
+	baseUp := make(map[string]int64, len(stored))
+	baseDown := make(map[string]int64, len(stored))
 	merged := make(map[string]groupAgg, len(derived)+len(stored))
 	for _, g := range stored {
 		merged[g.Name] = groupAgg{}
+		baseUp[g.Name] = g.ResetUp
+		baseDown[g.Name] = g.ResetDown
 	}
 	for _, g := range derived {
-		merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed, up: g.Up, down: g.Down}
+		merged[g.Name] = groupAgg{count: g.ClientCount, up: g.Up, down: g.Down}
 	}
 	out := make([]GroupSummary, 0, len(merged))
 	for name, agg := range merged {
-		out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic, Up: agg.up, Down: agg.down})
+		up := agg.up - baseUp[name]
+		if up < 0 {
+			up = 0
+		}
+		down := agg.down - baseDown[name]
+		if down < 0 {
+			down = 0
+		}
+		out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: up + down, Up: up, Down: down})
 	}
 	sort.Slice(out, func(i, j int) bool {
 		return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
@@ -77,6 +88,34 @@ func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
 	return emails, nil
 }
 
+func (s *ClientService) ResetGroupTraffic(name string) error {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return common.NewError("group name is required")
+	}
+	db := database.GetDB()
+	var agg struct {
+		Up   int64
+		Down int64
+	}
+	if err := db.Table("clients AS c").
+		Select("COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
+		Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
+		Where("c.group_name = ?", name).
+		Scan(&agg).Error; err != nil {
+		return err
+	}
+	var count int64
+	if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil {
+		return err
+	}
+	if count == 0 {
+		return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.Up, ResetDown: agg.Down}).Error
+	}
+	return db.Model(&model.ClientGroup{}).Where("name = ?", name).
+		Updates(map[string]any{"reset_up": agg.Up, "reset_down": agg.Down}).Error
+}
+
 func (s *ClientService) CreateGroup(name string) error {
 	name = strings.TrimSpace(name)
 	if name == "" {

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

@@ -5,6 +5,7 @@ import (
 	"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/xray"

+ 1 - 1
internal/web/service/client_traffic.go

@@ -84,7 +84,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 		if err == nil && !rec.Enable {
 			updated := rec.ToClient()
 			updated.Enable = true
-			s.Update(inboundSvc, rec.Id, *updated)
+			_, _ = s.Update(inboundSvc, rec.Id, *updated)
 		}
 	}
 

+ 1 - 0
internal/web/service/del_shared_email_runtime_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 

+ 6 - 5
internal/web/service/email/email.go

@@ -1,6 +1,7 @@
 package email
 
 import (
+	"context"
 	"crypto/tls"
 	"fmt"
 	"net"
@@ -115,10 +116,10 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 
 	switch encryptionType {
 	case "tls":
-		conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
+		conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
 			ServerName:         host,
 			InsecureSkipVerify: false,
-		})
+		}}).DialContext(context.Background(), "tcp", addr)
 	default:
 		conn, err = dialer.Dial("tcp", addr)
 	}
@@ -188,10 +189,10 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
 	// Dial with explicit timeout
 	dialer := &net.Dialer{Timeout: 10 * time.Second}
-	conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
+	conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
 		ServerName:         host,
 		InsecureSkipVerify: false,
-	})
+	}}).DialContext(context.Background(), "tcp", addr)
 	if err != nil {
 		return err
 	}
@@ -289,7 +290,7 @@ func buildMessage(from string, to []string, subject, body string) []byte {
 	}
 	var msg strings.Builder
 	for k, v := range headers {
-		msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
+		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 	}
 	msg.WriteString("\r\n")
 	msg.WriteString(body)

+ 2 - 1
internal/web/service/fallback.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"errors"
 	"fmt"
 	"strings"
 
@@ -46,7 +47,7 @@ func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback
 		Where("child_id = ?", childId).
 		Order("sort_order ASC, id ASC").
 		First(&row).Error
-	if err == gorm.ErrRecordNotFound {
+	if errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, nil
 	}
 	if err != nil {

+ 20 - 9
internal/web/service/inbound.go

@@ -5,6 +5,7 @@ package service
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net"
 	"sort"
@@ -153,7 +154,7 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	s.enrichClientStats(db, inbounds)
@@ -196,7 +197,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	s.annotateFallbackParents(db, inbounds)
@@ -319,7 +320,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 		Where("user_id = ?", userId).
 		Order("id ASC").
 		Scan(&rows).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	out := make([]InboundOption, 0, len(rows))
@@ -343,7 +344,7 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	s.enrichClientStats(db, inbounds)
@@ -354,7 +355,7 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	err := db.Model(model.Inbound{}).Where("traffic_reset = ?", period).Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	return inbounds, nil
@@ -362,7 +363,7 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo
 
 func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
 	settings := map[string][]model.Client{}
-	json.Unmarshal([]byte(inbound.Settings), &settings)
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 	if settings == nil {
 		return nil, fmt.Errorf("setting is null")
 	}
@@ -705,6 +706,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, err
 	}
 
+	// Legacy import: an inbound exported from a build that predated the hosts
+	// table carries its external proxies inline in streamSettings.externalProxy.
+	// The startup migration that converts those to host rows runs once and is
+	// gated off afterwards, so it never sees a freshly imported inbound —
+	// reproduce it here. No-op for inbounds without externalProxy (everything the
+	// current UI builds), so this only fires on such imports.
+	if _, err = database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
+
 	// Before the deferred commit, so a node in "selected" sync mode cannot
 	// sweep the new central row in the gap before its tag is allowed.
 	if inbound.NodeID != nil {
@@ -1338,7 +1349,7 @@ func (s *InboundService) GetInboundTags() (string, error) {
 	db := database.GetDB()
 	var inboundTags []string
 	err := db.Model(model.Inbound{}).Select("tag").Find(&inboundTags).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return "", err
 	}
 	tags, _ := json.Marshal(inboundTags)
@@ -1349,7 +1360,7 @@ func (s *InboundService) GetClientReverseTags() (string, error) {
 	db := database.GetDB()
 	var inbounds []model.Inbound
 	err := db.Model(model.Inbound{}).Select("settings").Where("protocol = ?", "vless").Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return "[]", err
 	}
 
@@ -1394,7 +1405,7 @@ func (s *InboundService) SearchInbounds(query string) ([]*model.Inbound, error)
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("remark like ?", "%"+query+"%").Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	return inbounds, nil

+ 1 - 0
internal/web/service/inbound_clients.go

@@ -8,6 +8,7 @@ import (
 	"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"

+ 3 - 3
internal/web/service/inbound_disable.go

@@ -27,7 +27,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 		if err != nil {
 			return false, 0, err
 		}
-		s.xrayApi.Init(p.GetAPIPort())
+		_ = s.xrayApi.Init(p.GetAPIPort())
 		for _, tag := range tags {
 			err1 := s.xrayApi.DelInbound(tag)
 			if err1 == nil {
@@ -141,7 +141,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 	}
 
 	if p != nil && len(localTargets) > 0 {
-		s.xrayApi.Init(p.GetAPIPort())
+		_ = s.xrayApi.Init(p.GetAPIPort())
 		for _, t := range localTargets {
 			err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
 			if err1 == nil {
@@ -231,7 +231,7 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
 		if _, hit := emails[email]; !hit {
 			continue
 		}
-		if cur, _ := entry["enable"].(bool); cur == false {
+		if cur, _ := entry["enable"].(bool); !cur {
 			continue
 		}
 		entry["enable"] = false

+ 103 - 0
internal/web/service/inbound_import_external_proxy_test.go

@@ -0,0 +1,103 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestAddInbound_ImportConvertsExternalProxyToHosts reproduces the panel report:
+// an inbound exported from a build that predated the hosts table carries its
+// external proxies inline in streamSettings.externalProxy. The one-time startup
+// migration that converts those to host rows is gated off after first run, so a
+// freshly imported inbound used to land with zero hosts (its external proxies
+// silently lost). AddInbound must convert them on import.
+func TestAddInbound_ImportConvertsExternalProxyToHosts(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	stream := `{
+		"network":"ws",
+		"wsSettings":{"path":"/req3","host":"astr.khafanha.ir"},
+		"security":"none",
+		"externalProxy":[
+			{"forceTls":"same","dest":"snapp.ir","port":8080,"remark":"","sni":"","alpn":[],"pinnedPeerCertSha256":[],"echConfigList":""},
+			{"forceTls":"tls","dest":"cdn.example.com","port":8443,"remark":"front","sni":"sni.example.com","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"}
+		]
+	}`
+	settings := `{"clients":[{"id":"6df5616b-ebfd-4186-86d5-4bce29fe8805","email":"imp_user","subId":"s-imp","enable":true}],"decryption":"none","encryption":"none"}`
+
+	in := &model.Inbound{
+		UserId:         1,
+		Tag:            "in-8080-tcp",
+		Enable:         true,
+		Listen:         "",
+		Port:           8080,
+		Protocol:       model.VLESS,
+		StreamSettings: stream,
+		Settings:       settings,
+	}
+	created, _, err := svc.AddInbound(in)
+	if err != nil {
+		t.Fatalf("import inbound: %v", err)
+	}
+
+	var hosts []model.Host
+	if err := database.GetDB().Where("inbound_id = ?", created.Id).Order("sort_order asc").Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 2 {
+		t.Fatalf("hosts = %d, want 2 (one per externalProxy entry)", len(hosts))
+	}
+
+	a := hosts[0]
+	if a.SortOrder != 0 || a.Security != "same" || a.Address != "snapp.ir" || a.Port != 8080 {
+		t.Fatalf("host A mapping wrong: %+v", a)
+	}
+	if a.Remark == "" {
+		t.Fatalf("host A remark must be backfilled for a blank externalProxy remark, got empty")
+	}
+
+	b := hosts[1]
+	if b.SortOrder != 1 || b.Security != "tls" || b.Address != "cdn.example.com" || b.Port != 8443 ||
+		b.Remark != "front" || b.Sni != "sni.example.com" || b.Fingerprint != "chrome" || b.EchConfigList != "ECHV" {
+		t.Fatalf("host B mapping wrong: %+v", b)
+	}
+	if len(b.Alpn) != 2 || b.Alpn[0] != "h2" || b.Alpn[1] != "h3" {
+		t.Fatalf("host B alpn = %v, want [h2 h3]", b.Alpn)
+	}
+	if len(b.PinnedPeerCertSha256) != 1 || b.PinnedPeerCertSha256[0] != "AAAA" {
+		t.Fatalf("host B pins = %v, want [AAAA]", b.PinnedPeerCertSha256)
+	}
+}
+
+// TestAddInbound_NoExternalProxyCreatesNoHosts guards the no-op path: an inbound
+// built by the current UI (no externalProxy) must not gain phantom host rows.
+func TestAddInbound_NoExternalProxyCreatesNoHosts(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	in := &model.Inbound{
+		UserId:         1,
+		Tag:            "in-9201-tcp",
+		Enable:         true,
+		Listen:         "0.0.0.0",
+		Port:           9201,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+		Settings:       `{"clients":[{"id":"77777777-7777-7777-7777-777777777777","email":"plain","subId":"s-plain","enable":true}],"decryption":"none","encryption":"none"}`,
+	}
+	created, _, err := svc.AddInbound(in)
+	if err != nil {
+		t.Fatalf("add inbound: %v", err)
+	}
+
+	var count int64
+	if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", created.Id).Count(&count).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("host count = %d, want 0", count)
+	}
+}

+ 6 - 5
internal/web/service/inbound_migration.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"strconv"
 	"strings"
@@ -93,12 +94,12 @@ func (s *InboundService) MigrationRequirements() {
 	// Fix inbounds based problems
 	var inbounds []*model.Inbound
 	err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return
 	}
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
-		json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
+		_ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
 		if raw, exists := settings["clients"]; exists && raw == nil {
 			settings["clients"] = []any{}
 		}
@@ -117,7 +118,7 @@ func (s *InboundService) MigrationRequirements() {
 
 				// Convert string tgId to int64
 				if _, ok := c["tgId"]; ok {
-					var tgId any = c["tgId"]
+					tgId := c["tgId"]
 					if tgIdStr, ok2 := tgId.(string); ok2 {
 						tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
 						if err == nil {
@@ -170,7 +171,7 @@ func (s *InboundService) MigrationRequirements() {
 				var count int64
 				tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
 				if count == 0 {
-					s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
+					_ = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
 				}
 			}
 		}
@@ -212,7 +213,7 @@ func (s *InboundService) MigrationRequirements() {
 	for _, ep := range externalProxy {
 		var reverses any
 		var stream map[string]any
-		json.Unmarshal([]byte(ep.StreamSettings), &stream)
+		_ = json.Unmarshal([]byte(ep.StreamSettings), &stream)
 		if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
 			if settings, ok := tlsSettings["settings"].(map[string]any); ok {
 				if domains, ok := settings["domains"].([]any); ok {

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

@@ -999,7 +999,7 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 	db := database.GetDB()
 	var rows []xray.ClientTraffic
 	err := db.Model(&xray.ClientTraffic{}).Select("email, last_online").Find(&rows).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
 	}
 	result := make(map[string]int64, len(rows))
@@ -1029,7 +1029,7 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
 	clients := make([]xray.ClientTraffic, 0, len(uniqEmails))
 	for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
 		var page []xray.ClientTraffic
-		if err := db.Where("email IN ?", batch).Find(&page).Error; err != nil && err != gorm.ErrRecordNotFound {
+		if err := db.Where("email IN ?", batch).Find(&page).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 			return nil, nil, err
 		}
 		clients = append(clients, page...)

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

@@ -8,6 +8,7 @@ import (
 type SubLinkProvider interface {
 	SubLinksForSubId(host, subId string) ([]string, error)
 	LinksForClient(host string, inbound *model.Inbound, email string) []string
+	LinksForInbounds(host string, inbounds []*model.Inbound) []string
 }
 
 var registeredSubLinkProvider SubLinkProvider
@@ -23,6 +24,17 @@ func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
 	return registeredSubLinkProvider.SubLinksForSubId(host, subId)
 }
 
+func (s *InboundService) GetAllInboundLinks(host string, userId int) ([]string, error) {
+	if registeredSubLinkProvider == nil {
+		return nil, common.NewError("sub link provider not registered")
+	}
+	inbounds, err := s.GetInbounds(userId)
+	if err != nil {
+		return nil, err
+	}
+	return registeredSubLinkProvider.LinksForInbounds(host, inbounds), nil
+}
+
 func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) {
 	if email == "" {
 		return nil, common.NewError("client email is required")

+ 8 - 7
internal/web/service/inbound_traffic.go

@@ -3,6 +3,7 @@ package service
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"strings"
 	"time"
@@ -224,7 +225,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 	}
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
-		json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
+		_ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
 		clients, ok := settings["clients"].([]any)
 		if ok {
 			var newClients []any
@@ -357,7 +358,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	}
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
-		json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
+		_ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
 		clients, _ := settings["clients"].([]any)
 		if len(clients) == 0 {
 			continue
@@ -760,7 +761,7 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
 			continue
 		}
 		if len(newClients) == 0 {
-			s.DelInbound(inbound.Id)
+			_, _ = s.DelInbound(inbound.Id)
 			continue
 		}
 		settings["clients"] = newClients
@@ -827,7 +828,7 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
 
 	// Retrieve inbounds where settings contain the given tgId
 	err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId": %d%%`, tgId)).Find(&inbounds).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
 		return nil, err
 	}
@@ -853,7 +854,7 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
 	for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
 		var page []*xray.ClientTraffic
 		if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
-			if err == gorm.ErrRecordNotFound {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
 				continue
 			}
 			logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
@@ -1008,7 +1009,7 @@ func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.Client
 	// Search for inbound settings that contain the query
 	err = db.Model(model.Inbound{}).Where("settings LIKE ?", "%\""+query+"\"%").First(inbound).Error
 	if err != nil {
-		if err == gorm.ErrRecordNotFound {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
 			logger.Warningf("Inbound settings containing query %s not found: %v", query, err)
 			return nil, err
 		}
@@ -1041,7 +1042,7 @@ func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.Client
 	// Retrieve ClientTraffic based on the found email
 	err = db.Model(xray.ClientTraffic{}).Where("email = ?", traffic.Email).First(traffic).Error
 	if err != nil {
-		if err == gorm.ErrRecordNotFound {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
 			logger.Warningf("ClientTraffic for email %s not found: %v", traffic.Email, err)
 			return nil, err
 		}

+ 1 - 1
internal/web/service/inbound_update_tag_test.go

@@ -49,7 +49,7 @@ func TestUpdateInbound_RegeneratesAutoTagOnPortChange(t *testing.T) {
 // the returned object) which is what the save would use.
 func TestUpdateInbound_NodeTagKeepsPrefixWhenNodeIdOmitted(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflictNode(t, "n1-in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, intPtr(1))
+	seedInboundConflictNode(t, "n1-in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, new(1))
 
 	var existing model.Inbound
 	if err := database.GetDB().Where("tag = ?", "n1-in-443-tcp").First(&existing).Error; err != nil {

+ 16 - 7
internal/web/service/integration/nord.go

@@ -1,6 +1,7 @@
 package integration
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -21,7 +22,11 @@ var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
 const maxResponseSize = 10 << 20
 
 func (s *NordService) GetCountries() (string, error) {
-	resp, err := nordHTTPClient.Get("https://api.nordvpn.com/v1/countries")
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.nordvpn.com/v1/countries", nil)
+	if reqErr != nil {
+		return "", reqErr
+	}
+	resp, err := nordHTTPClient.Do(req)
 	if err != nil {
 		return "", err
 	}
@@ -44,7 +49,11 @@ func (s *NordService) GetServers(countryId string) (string, error) {
 		}
 	}
 	url := fmt.Sprintf("https://api.nordvpn.com/v2/servers?limit=0&filters[servers_technologies][id]=35&filters[country_id]=%s", countryId)
-	resp, err := nordHTTPClient.Get(url)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	if reqErr != nil {
+		return "", reqErr
+	}
+	resp, err := nordHTTPClient.Do(req)
 	if err != nil {
 		return "", err
 	}
@@ -89,7 +98,7 @@ func (s *NordService) SetKey(privateKey string) (string, error) {
 		"token":       "",
 	}
 	data, _ := json.Marshal(nordData)
-	err := s.SettingService.SetNord(string(data))
+	err := s.SetNord(string(data))
 	if err != nil {
 		return "", err
 	}
@@ -98,7 +107,7 @@ func (s *NordService) SetKey(privateKey string) (string, error) {
 
 func (s *NordService) GetCredentials(token string) (string, error) {
 	url := "https://api.nordvpn.com/v1/users/services/credentials"
-	req, err := http.NewRequest("GET", url, nil)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
 	if err != nil {
 		return "", err
 	}
@@ -134,7 +143,7 @@ func (s *NordService) GetCredentials(token string) (string, error) {
 		"token":       token,
 	}
 	data, _ := json.Marshal(nordData)
-	err = s.SettingService.SetNord(string(data))
+	err = s.SetNord(string(data))
 	if err != nil {
 		return "", err
 	}
@@ -143,9 +152,9 @@ func (s *NordService) GetCredentials(token string) (string, error) {
 }
 
 func (s *NordService) GetNordData() (string, error) {
-	return s.SettingService.GetNord()
+	return s.GetNord()
 }
 
 func (s *NordService) DelNordData() error {
-	return s.SettingService.SetNord("")
+	return s.SetNord("")
 }

+ 9 - 8
internal/web/service/integration/warp.go

@@ -2,6 +2,7 @@ package integration
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -27,11 +28,11 @@ const (
 )
 
 func (s *WarpService) GetWarpData() (string, error) {
-	return s.SettingService.GetWarp()
+	return s.GetWarp()
 }
 
 func (s *WarpService) DelWarpData() error {
-	return s.SettingService.SetWarp("")
+	return s.SetWarp("")
 }
 
 func (s *WarpService) GetWarpConfig() (string, error) {
@@ -41,7 +42,7 @@ func (s *WarpService) GetWarpConfig() (string, error) {
 	}
 
 	url := fmt.Sprintf("%s/reg/%s", warpAPIBase, warpData["device_id"])
-	req, err := http.NewRequest(http.MethodGet, url, nil)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
 	if err != nil {
 		return "", err
 	}
@@ -67,7 +68,7 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
 		return "", err
 	}
 
-	req, err := http.NewRequest(http.MethodPost, warpAPIBase+"/reg", bytes.NewReader(reqBody))
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, warpAPIBase+"/reg", bytes.NewReader(reqBody))
 	if err != nil {
 		return "", err
 	}
@@ -116,7 +117,7 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
 	if err != nil {
 		return "", err
 	}
-	if err := s.SettingService.SetWarp(string(warpJSON)); err != nil {
+	if err := s.SetWarp(string(warpJSON)); err != nil {
 		return "", err
 	}
 
@@ -142,7 +143,7 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 		return "", err
 	}
 
-	req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(reqBody))
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, url, bytes.NewReader(reqBody))
 	if err != nil {
 		return "", err
 	}
@@ -167,7 +168,7 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	if err := s.SettingService.SetWarp(string(newWarpData)); err != nil {
+	if err := s.SetWarp(string(newWarpData)); err != nil {
 		return "", err
 	}
 	return string(newWarpData), nil
@@ -213,7 +214,7 @@ func (s *WarpService) ChangeWarpIP() (string, error) {
 
 // loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
 func (s *WarpService) loadWarpCreds() (map[string]string, error) {
-	warp, err := s.SettingService.GetWarp()
+	warp, err := s.GetWarp()
 	if err != nil {
 		return nil, err
 	}

+ 1 - 1
internal/web/service/node.go

@@ -832,7 +832,7 @@ func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func
 		return
 	}
 
-	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
 	if err != nil {
 		fn("")
 		return

+ 3 - 0
internal/web/service/node_bulk_dispatch_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"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/web/runtime"
@@ -33,10 +34,12 @@ func (f *fakeNodeRuntime) UpdateUser(context.Context, *model.Inbound, string, mo
 	f.updateUser.Add(1)
 	return nil
 }
+
 func (f *fakeNodeRuntime) DeleteUser(context.Context, *model.Inbound, string) error {
 	f.deleteUser.Add(1)
 	return nil
 }
+
 func (f *fakeNodeRuntime) AddClient(context.Context, *model.Inbound, model.Client) error {
 	f.addClient.Add(1)
 	return nil

+ 2 - 1
internal/web/service/node_client_traffic_sum_test.go

@@ -5,11 +5,12 @@ import (
 	"path/filepath"
 	"testing"
 
+	"gorm.io/gorm"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
-	"gorm.io/gorm"
 )
 
 func initTrafficTestDB(t *testing.T) *gorm.DB {

+ 1 - 0
internal/web/service/node_mtls_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/go-playground/validator/v10"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 

+ 2 - 1
internal/web/service/outbound/outbound.go

@@ -1,6 +1,7 @@
 package outbound
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net"
@@ -196,7 +197,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
 	r := TestEndpointResult{Address: endpoint}
 	start := time.Now()
-	conn, err := net.DialTimeout("tcp", endpoint, timeout)
+	conn, err := (&net.Dialer{Timeout: timeout}).DialContext(context.Background(), "tcp", endpoint)
 	r.Delay = time.Since(start).Milliseconds()
 	if err != nil {
 		r.Error = err.Error()

+ 8 - 8
internal/web/service/outbound/probe_http.go

@@ -101,7 +101,7 @@ func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allO
 func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) {
 	var raw []json.RawMessage
 	if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil {
-		return nil, fmt.Errorf("invalid outbounds JSON: %v", err)
+		return nil, fmt.Errorf("invalid outbounds JSON: %w", err)
 	}
 	if len(raw) > maxBatchItems {
 		return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems)
@@ -253,7 +253,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string) (retryPerItem bool, err error) {
 	ports, release, err := reserveLoopbackPorts(len(items))
 	if err != nil {
-		return false, fmt.Errorf("Failed to reserve test ports: %v", err)
+		return false, fmt.Errorf("Failed to reserve test ports: %w", err)
 	}
 	defer release()
 
@@ -261,14 +261,14 @@ func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL strin
 
 	configPath, err := createTestConfigPath()
 	if err != nil {
-		return false, fmt.Errorf("Failed to create test config path: %v", err)
+		return false, fmt.Errorf("Failed to create test config path: %w", err)
 	}
 	defer os.Remove(configPath)
 
 	proc := newBatchProcess(cfg, configPath)
 	defer func() {
 		if proc.IsRunning() {
-			proc.Stop()
+			_ = proc.Stop()
 		}
 	}()
 
@@ -279,9 +279,9 @@ func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL strin
 	if err := proc.Start(); err != nil {
 		if errors.Is(err, fs.ErrNotExist) {
 			// Binary missing — per-item retries would all fail the same way.
-			return false, fmt.Errorf("Failed to start test xray instance: %v", err)
+			return false, fmt.Errorf("Failed to start test xray instance: %w", err)
 		}
-		return true, fmt.Errorf("Failed to start test xray instance: %v", err)
+		return true, fmt.Errorf("Failed to start test xray instance: %w", err)
 	}
 
 	if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil {
@@ -330,7 +330,7 @@ func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *p
 			if !proc.IsRunning() {
 				return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true}
 			}
-			conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
+			conn, err := (&net.Dialer{Timeout: 100 * time.Millisecond}).DialContext(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port))
 			if err == nil {
 				conn.Close()
 				break
@@ -529,7 +529,7 @@ func reserveLoopbackPorts(n int) ([]int, func(), error) {
 	}
 	ports := make([]int, 0, n)
 	for range n {
-		l, err := net.Listen("tcp", "127.0.0.1:0")
+		l, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
 		if err != nil {
 			release()
 			return nil, nil, err

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

@@ -281,7 +281,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		return rejectPrivateHost(ctx, req.URL.Hostname())
 	}
 
-	req, err := http.NewRequest("GET", sub.Url, nil)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sub.Url, nil)
 	if err != nil {
 		s.recordError(sub, err)
 		return nil, err
@@ -295,7 +295,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 	}
 	defer resp.Body.Close()
 
-	if resp.StatusCode != 200 {
+	if resp.StatusCode != http.StatusOK {
 		err := fmt.Errorf("http %d", resp.StatusCode)
 		s.recordError(sub, err)
 		return nil, err

+ 14 - 5
internal/web/service/panel/panel.go

@@ -1,6 +1,7 @@
 package panel
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -157,7 +158,7 @@ func (s *PanelService) startUpdate(useDev bool) error {
 
 	if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
 		unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
-		cmd := exec.Command(systemdRun,
+		cmd := exec.CommandContext(context.Background(), systemdRun,
 			"--unit", unitName,
 			"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
 			"--setenv", "XUI_SERVICE="+serviceFolder,
@@ -179,7 +180,7 @@ func (s *PanelService) startUpdate(useDev bool) error {
 		}
 	}
 
-	cmd := exec.Command(bash, "-lc", updateScript)
+	cmd := exec.CommandContext(context.Background(), bash, "-lc", updateScript)
 	cmd.Env = append(os.Environ(),
 		"XUI_MAIN_FOLDER="+mainFolder,
 		"XUI_SERVICE="+serviceFolder,
@@ -199,7 +200,11 @@ func (s *PanelService) startUpdate(useDev bool) error {
 
 func downloadPanelUpdater() (string, error) {
 	client := (&service.SettingService{}).NewProxiedHTTPClient(15 * time.Second)
-	resp, err := client.Get(panelUpdaterURL)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, panelUpdaterURL, nil)
+	if reqErr != nil {
+		return "", fmt.Errorf("download panel updater: %w", reqErr)
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return "", fmt.Errorf("download panel updater: %w", err)
 	}
@@ -228,7 +233,7 @@ func downloadPanelUpdater() (string, error) {
 	if n > maxPanelUpdaterBytes {
 		return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
 	}
-	if err := file.Chmod(0700); err != nil {
+	if err := file.Chmod(0o700); err != nil {
 		return "", err
 	}
 	ok = true
@@ -254,7 +259,11 @@ func fetchPanelRelease(tag string) (*service.Release, error) {
 		url = "https://api.github.com/repos/MHSanaei/3x-ui/releases/tags/" + tag
 	}
 	client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
-	resp, err := client.Get(url)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	if reqErr != nil {
+		return nil, reqErr
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return nil, err
 	}

+ 6 - 7
internal/web/service/panel/user.go

@@ -3,14 +3,15 @@ package panel
 import (
 	"errors"
 
+	"github.com/xlzd/gotp"
+	"gorm.io/gorm"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
-	"github.com/xlzd/gotp"
-	"gorm.io/gorm"
 )
 
 // UserService provides business logic for user management and authentication.
@@ -43,7 +44,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 		Where("username = ?", username).
 		First(user).
 		Error
-	if err == gorm.ErrRecordNotFound {
+	if errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, errors.New("invalid credentials")
 	} else if err != nil {
 		logger.Warning("check user err:", err)
@@ -89,7 +90,6 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 
 	if twoFactorEnable {
 		twoFactorToken, err := s.settingService.GetTwoFactorToken()
-
 		if err != nil {
 			logger.Warning("check two factor token err:", err)
 			return nil, err
@@ -114,7 +114,6 @@ func (s *UserService) BumpLoginEpoch() error {
 func (s *UserService) UpdateUser(id int, username string, password string) error {
 	db := database.GetDB()
 	hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
-
 	if err != nil {
 		return err
 	}
@@ -125,8 +124,8 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
 	}
 
 	if twoFactorEnable {
-		s.settingService.SetTwoFactorEnable(false)
-		s.settingService.SetTwoFactorToken("")
+		_ = s.settingService.SetTwoFactorEnable(false)
+		_ = s.settingService.SetTwoFactorToken("")
 	}
 
 	return db.Model(model.User{}).

+ 4 - 4
internal/web/service/panel/websocket.go

@@ -65,7 +65,7 @@ func (s *WebSocketService) readPump(client *websocket.Client, conn *ws.Conn) {
 	}()
 
 	conn.SetReadLimit(wsClientReadLimit)
-	conn.SetReadDeadline(time.Now().Add(wsPongWait))
+	_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
 	conn.SetPongHandler(func(string) error {
 		return conn.SetReadDeadline(time.Now().Add(wsPongWait))
 	})
@@ -94,9 +94,9 @@ func (s *WebSocketService) writePump(client *websocket.Client, conn *ws.Conn) {
 	for {
 		select {
 		case msg, ok := <-client.Send:
-			conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
+			_ = conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
 			if !ok {
-				conn.WriteMessage(ws.CloseMessage, []byte{})
+				_ = conn.WriteMessage(ws.CloseMessage, []byte{})
 				return
 			}
 			if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
@@ -105,7 +105,7 @@ func (s *WebSocketService) writePump(client *websocket.Client, conn *ws.Conn) {
 			}
 
 		case <-ticker.C:
-			conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
+			_ = conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
 			if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
 				logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
 				return

+ 7 - 9
internal/web/service/port_conflict_test.go

@@ -6,10 +6,11 @@ import (
 	"sync"
 	"testing"
 
+	"github.com/op/go-logging"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/op/go-logging"
 )
 
 // the panel logger is a process-wide singleton. init it once per test
@@ -57,9 +58,6 @@ func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protoco
 	}
 }
 
-//go:fix inline
-func intPtr(v int) *int { return new(v) }
-
 func TestInboundTransports(t *testing.T) {
 	cases := []struct {
 		name           string
@@ -410,7 +408,7 @@ func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
 		Port:           5000,
 		Protocol:       model.VLESS,
 		StreamSettings: `{"network":"tcp"}`,
-		NodeID:         intPtr(1),
+		NodeID:         new(1),
 	}
 	got, err := svc.resolveInboundTag(pushed, 0)
 	if err != nil {
@@ -481,7 +479,7 @@ func TestGenerateInboundTag_NodePrefix(t *testing.T) {
 		Listen:   "0.0.0.0",
 		Port:     443,
 		Protocol: model.VLESS,
-		NodeID:   intPtr(1),
+		NodeID:   new(1),
 	}
 	got, err := svc.generateInboundTag(in, 0)
 	if err != nil {
@@ -503,7 +501,7 @@ func TestGenerateInboundTag_NodePrefixedDoesNotCollideWithLocal(t *testing.T) {
 		Listen:   "0.0.0.0",
 		Port:     443,
 		Protocol: model.VLESS,
-		NodeID:   intPtr(1),
+		NodeID:   new(1),
 	}
 	got, err := svc.generateInboundTag(in, 0)
 	if err != nil {
@@ -653,7 +651,7 @@ func TestIsAutoGeneratedTag(t *testing.T) {
 		{"canonical", "in-443-tcp", 443, nil, tcp, true},
 		{"canonical udp", "in-443-udp", 443, nil, transportUDP, true},
 		{"dedup suffix", "in-443-tcp-2", 443, nil, tcp, true},
-		{"node prefixed", "n1-in-443-tcp", 443, intPtr(1), tcp, true},
+		{"node prefixed", "n1-in-443-tcp", 443, new(1), tcp, true},
 		{"legacy listen-scoped is now custom", "in-127.0.0.1:443-tcp", 443, nil, tcp, false},
 		{"custom tag", "my-cool-tag", 443, nil, tcp, false},
 		{"stale port", "in-443-tcp", 8443, nil, tcp, false},
@@ -708,7 +706,7 @@ func TestCheckPortConflict_ReservedAPIPortAllowedOnNode(t *testing.T) {
 		Port:           defaultXrayAPIPort,
 		Protocol:       model.VLESS,
 		StreamSettings: `{"network":"tcp"}`,
-		NodeID:         intPtr(1),
+		NodeID:         new(1),
 	}
 	if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
 		t.Fatalf("node inbound on the reserved API port must be allowed; got=%v err=%v", got, err)

+ 54 - 27
internal/web/service/server.go

@@ -4,6 +4,7 @@ import (
 	"archive/zip"
 	"bufio"
 	"bytes"
+	"context"
 	"crypto/sha256"
 	"crypto/x509"
 	"encoding/hex"
@@ -233,7 +234,7 @@ func (s *ServerService) isFail2banInstalled() bool {
 		return s.fail2banInstalled
 	}
 
-	err := exec.Command("fail2ban-client", "-h").Run()
+	err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run()
 	s.fail2banInstalled = err == nil
 	s.fail2banCheckedAt = time.Now()
 	return s.fail2banInstalled
@@ -351,7 +352,11 @@ func getPublicIP(url string) string {
 		Timeout: 3 * time.Second,
 	}
 
-	resp, err := client.Get(url)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	if reqErr != nil {
+		return "N/A"
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return "N/A"
 	}
@@ -772,7 +777,11 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
 		bufferSize = 8192
 	)
 
-	resp, err := s.settingService.NewProxiedHTTPClient(10 * time.Second).Get(XrayURL)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, XrayURL, nil)
+	if reqErr != nil {
+		return nil, reqErr
+	}
+	resp, err := s.settingService.NewProxiedHTTPClient(10 * time.Second).Do(req)
 	if err != nil {
 		return nil, err
 	}
@@ -872,7 +881,11 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
 	fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
 	url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
 	client := s.settingService.NewProxiedHTTPClient(60 * time.Second)
-	resp, err := client.Get(url)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	if reqErr != nil {
+		return "", reqErr
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return "", err
 	}
@@ -934,7 +947,11 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
 // fetchXrayDigestSHA256 downloads the .dgst sidecar XTLS publishes next to each
 // release asset and returns the SHA2-256 hex digest it lists.
 func (s *ServerService) fetchXrayDigestSHA256(client *http.Client, dgstURL string) (string, error) {
-	resp, err := client.Get(dgstURL)
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, dgstURL, nil)
+	if reqErr != nil {
+		return "", fmt.Errorf("download xray checksum: %w", reqErr)
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return "", fmt.Errorf("download xray checksum: %w", err)
 	}
@@ -1009,7 +1026,7 @@ func (s *ServerService) UpdateXray(version string) error {
 			return err
 		}
 		defer zipFile.Close()
-		if err := os.MkdirAll(filepath.Dir(fileName), 0755); err != nil {
+		if err := os.MkdirAll(filepath.Dir(fileName), 0o755); err != nil {
 			return err
 		}
 		tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
@@ -1031,7 +1048,7 @@ func (s *ServerService) UpdateXray(version string) error {
 		if n > maxXrayBinaryBytes {
 			return fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes)
 		}
-		if err := tmpFile.Chmod(0755); err != nil {
+		if err := tmpFile.Chmod(0o755); err != nil {
 			return err
 		}
 		if err := tmpFile.Close(); err != nil {
@@ -1099,7 +1116,7 @@ func (s *ServerService) GetLogs(count string, level string, syslog string) []str
 		}
 
 		// Use hardcoded command with validated parameters
-		cmd := exec.Command("journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level)
+		cmd := exec.CommandContext(context.Background(), "journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level)
 		var out bytes.Buffer
 		cmd.Stdout = &out
 		err = cmd.Run()
@@ -1121,8 +1138,8 @@ func (s *ServerService) GetXrayLogs(
 	showBlocked string,
 	showProxy string,
 	freedoms []string,
-	blackholes []string) []LogEntry {
-
+	blackholes []string,
+) []LogEntry {
 	const (
 		Direct = iota
 		Blocked
@@ -1149,12 +1166,12 @@ func (s *ServerService) GetXrayLogs(
 		line := strings.TrimSpace(scanner.Text())
 
 		if line == "" || strings.Contains(line, "api -> api") {
-			//skipping empty lines and api calls
+			// skipping empty lines and api calls
 			continue
 		}
 
 		if filter != "" && !strings.Contains(line, filter) {
-			//applying filter if it's not empty
+			// applying filter if it's not empty
 			continue
 		}
 
@@ -1298,18 +1315,28 @@ func (s *ServerService) GetDb() ([]byte, error) {
 
 // BackupFilename returns the filename for a database backup, named after the
 // panel's address so a downloaded or Telegram-sent backup identifies the server
-// it came from. requestHost is the browser's address: the getDb handler passes
-// c.Request.Host so a panel download is named after whatever address the user
-// reached the panel with, no Listen Domain needed. The Telegram bot has no
-// request and passes "", falling back to the configured Listen Domain (webDomain)
-// and then the public IP. The extension is .dump on PostgreSQL and .db on SQLite;
-// the base falls back to "x-ui" when no address is known.
+// it came from, followed by the current date and time (_YYYY-MM-DD_HHMMSS) so
+// files accumulated in Telegram chat history group by server then sort
+// chronologically and same-day backups stay distinct. requestHost is the
+// browser's address: the getDb handler passes c.Request.Host so a panel download
+// is named after whatever address the user reached the panel with, no Listen
+// Domain needed. The Telegram bot has no request and passes "", falling back to
+// the configured Listen Domain (webDomain) and then the public IP. The extension
+// is .dump on PostgreSQL and .db on SQLite; the base falls back to "x-ui" when
+// no address is known.
 func (s *ServerService) BackupFilename(requestHost string) string {
 	ext := ".db"
 	if database.IsPostgres() {
 		ext = ".dump"
 	}
-	return s.backupHost(requestHost) + ext
+	return s.backupHost(requestHost) + backupDateSuffix(time.Now()) + ext
+}
+
+// backupDateSuffix returns the _YYYY-MM-DD_HHMMSS chronological suffix appended
+// after the host in backup filenames. Uses server-local time for consistency
+// with the timestamp printed in the Telegram backup message body.
+func backupDateSuffix(now time.Time) string {
+	return "_" + now.Format("2006-01-02_150405")
 }
 
 // backupHost picks the address used to name backup files: the browser's request
@@ -1570,7 +1597,7 @@ func (s *ServerService) exportPostgresDB() ([]byte, error) {
 	if err != nil {
 		return nil, common.NewErrorf("invalid PostgreSQL DSN: %v", err)
 	}
-	cmd := exec.Command(bin, "--format=custom", "--no-owner", "--no-privileges", "--dbname", dbname)
+	cmd := exec.CommandContext(context.Background(), bin, "--format=custom", "--no-owner", "--no-privileges", "--dbname", dbname)
 	cmd.Env = env
 	var out, stderr bytes.Buffer
 	cmd.Stdout = &out
@@ -1632,7 +1659,7 @@ func (s *ServerService) importPostgresDB(file multipart.File) error {
 		logger.Warningf("Failed to close existing DB before restore: %v", errClose)
 	}
 
-	cmd := exec.Command(bin,
+	cmd := exec.CommandContext(context.Background(), bin,
 		"--clean", "--if-exists", "--no-owner", "--no-privileges",
 		"--single-transaction", "--dbname", dbname, tempPath,
 	)
@@ -1711,7 +1738,7 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 
 	downloadFile := func(url, destPath string) error {
 		var req *http.Request
-		req, err := http.NewRequest("GET", url, nil)
+		req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
 		if err != nil {
 			return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err)
 		}
@@ -1808,7 +1835,7 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 
 func (s *ServerService) GetNewX25519Cert() (any, error) {
 	// Run the command
-	cmd := exec.Command(xray.GetBinaryPath(), "x25519")
+	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "x25519")
 	var out bytes.Buffer
 	cmd.Stdout = &out
 	err := cmd.Run()
@@ -1834,7 +1861,7 @@ func (s *ServerService) GetNewX25519Cert() (any, error) {
 
 func (s *ServerService) GetNewmldsa65() (any, error) {
 	// Run the command
-	cmd := exec.Command(xray.GetBinaryPath(), "mldsa65")
+	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mldsa65")
 	var out bytes.Buffer
 	cmd.Stdout = &out
 	err := cmd.Run()
@@ -2038,7 +2065,7 @@ func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
 
 func (s *ServerService) GetNewEchCert(sni string) (any, error) {
 	// Run the command
-	cmd := exec.Command(xray.GetBinaryPath(), "tls", "ech", "--serverName", sni)
+	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "tls", "ech", "--serverName", sni)
 	var out bytes.Buffer
 	cmd.Stdout = &out
 	err := cmd.Run()
@@ -2061,7 +2088,7 @@ func (s *ServerService) GetNewEchCert(sni string) (any, error) {
 }
 
 func (s *ServerService) GetNewVlessEnc() (any, error) {
-	cmd := exec.Command(xray.GetBinaryPath(), "vlessenc")
+	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "vlessenc")
 	var out bytes.Buffer
 	cmd.Stdout = &out
 	if err := cmd.Run(); err != nil {
@@ -2156,7 +2183,7 @@ func (s *ServerService) GetNewUUID() (map[string]string, error) {
 
 func (s *ServerService) GetNewmlkem768() (any, error) {
 	// Run the command
-	cmd := exec.Command(xray.GetBinaryPath(), "mlkem768")
+	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mlkem768")
 	var out bytes.Buffer
 	cmd.Stdout = &out
 	err := cmd.Run()

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

@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"

+ 1 - 0
internal/web/service/sync_scale_postgres_test.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 

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

@@ -283,7 +283,7 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 				logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
 				return err
 			}
-			parsedAdminIds = append(parsedAdminIds, int64(id))
+			parsedAdminIds = append(parsedAdminIds, id)
 		}
 	}
 	tgBotMutex.Lock()
@@ -472,7 +472,7 @@ func StopBot() {
 	userStateMgr.reset()
 
 	if handler != nil {
-		handler.Stop()
+		_ = handler.Stop()
 	}
 
 	if cancel != nil {

+ 9 - 10
internal/web/service/tgbot/tgbot_client.go

@@ -74,13 +74,13 @@ func (t *Tgbot) BuildClientDraftMessage() string {
 
 	var b strings.Builder
 	b.WriteString("📝 *New client draft*\r\n")
-	b.WriteString(fmt.Sprintf("📧 Email: `%s`\r\n", client_Email))
-	b.WriteString(fmt.Sprintf("🔗 Attached: %s\r\n", attached))
-	b.WriteString(fmt.Sprintf("📊 Traffic: %s\r\n", traffic))
-	b.WriteString(fmt.Sprintf("📅 Expire: %s\r\n", expiry))
-	b.WriteString(fmt.Sprintf("🔢 IP limit: %s\r\n", ipLimit))
-	b.WriteString(fmt.Sprintf("👤 TG user: %s\r\n", tgID))
-	b.WriteString(fmt.Sprintf("💬 Comment: %s\r\n", comment))
+	fmt.Fprintf(&b, "📧 Email: `%s`\r\n", client_Email)
+	fmt.Fprintf(&b, "🔗 Attached: %s\r\n", attached)
+	fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
+	fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
+	fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit)
+	fmt.Fprintf(&b, "👤 TG user: %s\r\n", tgID)
+	fmt.Fprintf(&b, "💬 Comment: %s\r\n", comment)
 	return b.String()
 }
 
@@ -216,7 +216,6 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
 		}
 		subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
 	} else {
-
 		subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
 	}
 
@@ -258,7 +257,7 @@ func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
 	}
 
 	// Try to fetch raw subscription links. Prefer plain text response.
-	req, err := http.NewRequest("GET", subURL, nil)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil)
 	if err != nil {
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
 		return
@@ -376,7 +375,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
 
 	// Also generate a few individual links' QRs (first up to 5)
 	subPageURL := subURL
-	req, err := http.NewRequest("GET", subPageURL, nil)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil)
 	if err == nil {
 		req.Header.Set("Accept", "text/plain, */*;q=0.1")
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

+ 1 - 5
internal/web/service/tgbot/tgbot_inbound.go

@@ -31,7 +31,7 @@ func (t *Tgbot) getInboundUsages() string {
 
 		clients, listErr := t.clientService.ListForInbound(nil, inbound.Id)
 		if listErr == nil {
-			info.WriteString(fmt.Sprintf("👥 Clients: %d\r\n", len(clients)))
+			fmt.Fprintf(&info, "👥 Clients: %d\r\n", len(clients))
 		}
 
 		if inbound.ExpiryTime == 0 {
@@ -126,11 +126,9 @@ func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.Inli
 			for _, client := range clients {
 				buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
 			}
-
 		} else {
 			return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
 		}
-
 	}
 	cols := 0
 	if len(buttons) < 6 {
@@ -252,11 +250,9 @@ func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error)
 			for _, client := range clients {
 				buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
 			}
-
 		} else {
 			return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
 		}
-
 	}
 	cols := 0
 	if len(buttons) < 6 {

+ 2 - 2
internal/web/service/tgbot/tgbot_report.go

@@ -49,7 +49,7 @@ func (t *Tgbot) SendBackupToAdmins() {
 		return
 	}
 	for i, adminId := range adminIds {
-		t.sendBackup(int64(adminId))
+		t.sendBackup(adminId)
 		// Add delay between sends to avoid Telegram rate limits
 		if i < len(adminIds)-1 {
 			time.Sleep(1 * time.Second)
@@ -63,7 +63,7 @@ func (t *Tgbot) sendExhaustedToAdmins() {
 		return
 	}
 	for _, adminId := range adminIds {
-		t.getExhausted(int64(adminId))
+		t.getExhausted(adminId)
 	}
 }
 

+ 8 - 11
internal/web/service/tgbot/tgbot_router.go

@@ -141,7 +141,6 @@ func (t *Tgbot) OnReceive() {
 					userStateMgr.clear(message.Chat.ID)
 					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 				}
-
 			} else {
 				if message.UsersShared != nil {
 					if checkAdmin(message.From.ID) {
@@ -167,7 +166,7 @@ func (t *Tgbot) OnReceive() {
 			return nil
 		}, th.AnyMessage())
 
-		h.Start()
+		_ = h.Start()
 	}()
 }
 
@@ -205,7 +204,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
 			if isAdmin {
 				t.searchClient(chatId, commandArgs[0])
 			} else {
-				t.getClientUsage(chatId, int64(message.From.ID), commandArgs[0])
+				t.getClientUsage(chatId, message.From.ID, commandArgs[0])
 			}
 		} else {
 			msg += t.I18nBot("tgbot.commands.usage")
@@ -595,12 +594,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 
 							if traffic.ExpiryTime > 0 {
 								if traffic.ExpiryTime-time.Now().Unix()*1000 < 0 {
-									date = -int64(days * 24 * 60 * 60000)
+									date = -(days * 24 * 60 * 60000)
 								} else {
-									date = traffic.ExpiryTime + int64(days*24*60*60000)
+									date = traffic.ExpiryTime + days*24*60*60000
 								}
 							} else {
-								date = traffic.ExpiryTime - int64(days*24*60*60000)
+								date = traffic.ExpiryTime - days*24*60*60000
 							}
 
 						}
@@ -685,12 +684,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				var date int64
 				if client_ExpiryTime > 0 {
 					if client_ExpiryTime-time.Now().Unix()*1000 < 0 {
-						date = -int64(days * 24 * 60 * 60000)
+						date = -(days * 24 * 60 * 60000)
 					} else {
-						date = client_ExpiryTime + int64(days*24*60*60000)
+						date = client_ExpiryTime + days*24*60*60000
 					}
 				} else {
-					date = client_ExpiryTime - int64(days*24*60*60000)
+					date = client_ExpiryTime - days*24*60*60000
 				}
 				client_ExpiryTime = date
 
@@ -1111,7 +1110,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				}
 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
 			}
-
 		}
 	}
 
@@ -1458,7 +1456,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 	case "get_sorted_traffic_usage_report":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		emails, err := t.inboundService.GetAllEmails()
-
 		if err != nil {
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove())
 			return

+ 3 - 3
internal/web/service/xray.go

@@ -143,7 +143,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 			continue
 		}
 		settings := map[string]any{}
-		json.Unmarshal([]byte(inbound.Settings), &settings)
+		_ = json.Unmarshal([]byte(inbound.Settings), &settings)
 
 		dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
 		if listErr != nil {
@@ -240,7 +240,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		if len(inbound.StreamSettings) > 0 {
 			// Unmarshal stream JSON
 			var stream map[string]any
-			json.Unmarshal([]byte(inbound.StreamSettings), &stream)
+			_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
 
 			// Remove the "settings" field under "tlsSettings" and "realitySettings"
 			tlsSettings, ok1 := stream["tlsSettings"].(map[string]any)
@@ -930,7 +930,7 @@ func (s *XrayService) RestartXray(isForce bool) error {
 			logger.Info("Xray config changes applied through the core API, no restart needed")
 			return nil
 		}
-		p.Stop()
+		_ = p.Stop()
 	}
 
 	p = xray.NewProcess(xrayConfig)

+ 1 - 1
internal/web/service/xray_setting.go

@@ -29,7 +29,7 @@ func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
 	if hoisted, err := EnsureStatsRouting(newXraySettings); err == nil {
 		newXraySettings = hoisted
 	}
-	return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings)
+	return s.saveSetting("xrayTemplateConfig", newXraySettings)
 }
 
 func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {

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

@@ -934,8 +934,8 @@
       "deleteSuccess": "تم مسح المجموعة من {count} عميل.",
       "resetTraffic": "إعادة تعيين حركة المرور",
       "resetConfirmTitle": "إعادة تعيين حركة المرور للمجموعة {name}؟",
-      "resetConfirmContent": "يصفر up/down لجميع {count} عميل في هذه المجموعة.",
-      "resetSuccess": "تمت إعادة تعيين حركة المرور لـ {count} عميل.",
+      "resetConfirmContent": "يعيد تعيين عداد حركة مرور المجموعة فقط؛ ولا تتأثر عدادات العملاء الفرديين.",
+      "resetSuccess": "تمت إعادة تعيين حركة مرور المجموعة {name}.",
       "adjustSuccess": "تم ضبط {count} عميل في {name}.",
       "emptyForAction": "هذه المجموعة فارغة.",
       "deleteGroupOnly": "حذف المجموعة (مع الاحتفاظ بالعملاء)",

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