Compare commits

..

23 Commits

Author SHA1 Message Date
Sanaei 8a8da88548 fix(frontend): isolate swagger deps from main vendor chunk
Keep swagger-ui-react and its transitive dependencies in the lazy swagger chunk so the initial panel bundle stays smaller. This avoids eager loading the OpenAPI UI on first paint while keeping the API docs route unchanged.
2026-08-12 16:11:47 +02:00
Sanaei 1f846c3cb2 fix(frontend): clean test validation output 2026-08-12 15:35:20 +02:00
Sanaei 1c255fc00c chore(frontend): bump npm dependencies
Refresh frontend package versions and regenerate the lockfile. This updates core UI and tooling packages including Ant Design, React Hook Form, Storybook, Vite, eslint/typescript-eslint, @noble/hashes, persian-calendar-suite, and swagger-ui-react to pick up the latest fixes and minor improvements.
2026-08-12 14:06:18 +02:00
dependabot[bot] 75032fd498 chore(deps): bump dompurify (#6193)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [dompurify](https://github.com/cure53/DOMPurify).


Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 13:40:13 +02:00
Sanaei ece1655939 fix(docs): prevent theme switch hydration mismatch 2026-08-06 20:29:00 +02:00
Sanaei cb902314db fix(docs): restore theme switch without runtime warnings
Move html/body shell and global css to root app layout to avoid hydration/script warnings from nested document nodes. Disable provider theme injection and add a custom script-free theme switch in shared layout slots.

Also migrate docs search static client initializer to ZBSearch (initDB), add zbsearch dependency, and align docs lint tooling with ESLint 9 compatibility so npm run lint passes.
2026-08-06 17:59:23 +02:00
n0ctal 7eacce6a46 chore(frontend): resolve the high-severity brace-expansion advisory (#6180)
npm audit --omit=dev --audit-level=high is a CI gate and it currently fails on
main: swagger-ui-react pulls @swagger-api/apidom-reference, which pins
minimatch, which resolves brace-expansion to 5.0.8 — the range covered by
GHSA-rgw5-rvv9-x895.

Pin the patched 5.0.9 through the existing swagger-ui-react overrides block
rather than globally: minimatch@3 under eslint-plugin-jsx-a11y still needs the
1.x line, and a blanket override would force v5 there too.
2026-08-06 16:29:03 +02:00
dependabot[bot] 199ddaf485 chore(deps-dev): bump brace-expansion (#6172)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 1.1.16 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.16...v1.1.18)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 16:27:30 +02:00
dependabot[bot] d142307366 chore(deps-dev): bump postcss (#6173)
Bumps the npm_and_yarn group with 1 update in the /docs directory: [postcss](https://github.com/postcss/postcss).


Updates `postcss` from 8.5.21 to 8.5.23
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.21...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 16:27:13 +02:00
Sanaei 3883882726 chore: bump frontend and Go dependencies
Updates multiple frontend packages (antd, react-hook-form, storybook, vite, swagger-ui-react, playwright, typescript-eslint, etc.) and Go dependencies (gopsutil, gorm postgres driver, pion/transport, ugorji/codec, genproto, and others). Also replaces `__dirname` with `import.meta.dirname` in vite.config.js for ESM compatibility.
2026-08-04 11:52:45 +02:00
Sanaei 216d18b3c4 chore(vscode): fix Linux paths in the task and launch configs
The "go: build" task hardcoded bin/3x-ui.exe, so building on Linux
produced a binary carrying a Windows extension. It now emits bin/3x-ui
and keeps the .exe name behind a windows override.

The Postgres launch config prepended C:\Program Files\PostgreSQL\18\bin
to PATH on every platform. Linux separates entries with ':', not ';', so
that string fused into the first real PATH entry and clobbered it. Moved
it into a windows block, which is where the pg_dump/pg_restore lookups
in ServerService need it anyway.
2026-08-02 12:40:11 +02:00
Sanaei 2a8c3bc0db fix(clients): stop a stale IP row from blocking a client edit
Saving a client walks every inbound it is attached to and calls
UpdateInboundClient, which re-keys the client's email in inbound_client_ips
to the spelling in the edited settings. The email match is EqualFold, so when
an inbound's settings JSON drifted in case from the client record the panel
issues a case-only rename of the tracking row.

inbound_client_ips.client_email is unique and case-sensitive, and the
IP-limit job keys its rows on whatever casing Xray reports, so both spellings
can already be present. The rename then aborts the whole edit with
"duplicate key value violates unique constraint
uni_inbound_client_ips_client_email" — the client could not be saved at all,
including when only adding an inbound to it.

The caller only renames onto an identity no live client holds, so a row on
the target email is stale IP tracking: delete it before renaming. The blob is
rebuilt by the next scan anyway.
2026-08-02 12:32:45 +02:00
Sanaei e71b75e99e docs(claude): correct enforced-guard claims and add the runtime dispatch rule
Fact-checked every line of CLAUDE.md against the tree. Six claims were wrong,
and two told an agent the opposite of the truth.

The file said nothing checks endpoints.ts against the Go routes and nothing
fails the build on a missing i18n key. Both guards exist and both run in
make verify: TestRouteRegistryContract diffs the real router against the
registry in both directions, and i18n-dead-keys.test.ts rejects a locale that
misses an en-US key as well as an en-US key nothing references. An agent
trusting the old text either skips a step it thinks is unenforced or is
blindsided when a "silent" omission turns the suite red.

The rest: the Go locale returns an empty string for an unknown key, not the raw
key; mtg-multi is a prebuilt binary fetched at build time, not a Go dependency
built from source; commits are type(area): summary, not <area>: summary, and
perf is in active use; make verify is the fast gate, not a mirror of CI, which
also runs race, vulncheck, a live-Postgres job where a SKIP is a failure, and a
fuzz smoke.

Add the five facts most likely to burn an agent, all reproduced before writing
them down. A fresh clone has no internal/web/dist, so go build dies on the embed
pattern while thirty-odd packages pass — it reads as a broken repo rather than a
missing make dist-stub. Every state-changing inbound/client op must dispatch
through runtime.Runtime; a direct xray/api.go call passes all local tests and
silently breaks every multi-node install, which is exactly what a hard rule is
for. Node 24 is required because make gen imports .ts directly. Postgres, xray
e2e and scale tests skip themselves without their env vars. An endpoint change
has a fourth step nothing checks: syncing docs/public/openapi.json.

Definition of done loses its first step — verify's gen-check already runs gen
and fails on a dirty generated diff.
2026-08-01 16:06:55 +02:00
Sanaei 5bc81dfd1d fix(node): stop the node sync from deleting clients it never meant to
A client that hit its quota or expiry was disabled, then destroyed on both
panels a few seconds later. Five defects fed the same hard delete.

ReconcileNode pushed buildRuntimeInboundForAPI, which strips disabled
clients. Every other call site targets an in-memory Xray config, where
dropping a user is harmless; a node target is a peer panel's DATABASE, so
the node deleted the row, stopped reporting it, and the master mirrored that
deletion back. Split the builder in two: buildInboundForNodePush injects
fallbacks only, buildInboundForLocalRuntime adds the strip on top. The names
now say which targets they are safe for.

setRemoteTrafficLocked trusted a config_dirty the caller sampled before the
snapshot round-trip. A client added inside that window commits on the same
serialized writer and marks the node dirty, but the merge still treated the
older snapshot as authoritative and deleted it. Re-read the flag inside the
writer.

In "selected" sync mode, FilterNodeSnapshot strips a deselected tag, but the
sweep loaded every inbound with node_id set, so deselecting a tag read as
"the node deleted it" and wiped an inbound the node still serves. Skip tags
outside the node's managed set.

A failed SyncInbound was logged and swallowed; on SQLite the transaction
still commits, and the sweep then deleted the innocent clients whose links
that failure had left unbuilt. Skip the sweep for such an inbound, and close
the trigger: SyncInbound now stores the trimmed email it looks up by, and
email validation rejects every unicode space rather than only U+0020.

ClientService.Delete tombstones up front and deliberately keeps the record
when an inbound fails, so the next attempt can retry the leftovers. The
tombstone did not lift with it, so the next merge dropped the client from
the synced settings and finished the deletion this path had refused. Add
withdrawClientTombstones on every failure path, in BulkDelete too.

Finally, make the sweep itself recoverable. "Ended the merge unattached" is
true for a real remote deletion and equally true for a bad merge, so it now
stamps sync_orphaned_at instead of deleting; any later merge that sees the
client attached clears the mark, and a reaper removes only what stayed
orphaned past the grace period. The traffic row survives that window too, or
a reclaimed client would come back with its usage, quota and expiry reset.
The mark is written by this sweep alone, so orphans from any other cause
keep their existing manual-cleanup semantics.
2026-08-01 15:19:08 +02:00
Sanaei f4b7b08e08 fix(ldap): stop auto-delete from wiping every client on an empty directory
FetchVlessFlags returns (empty map, nil) whenever the bind succeeds but the
search yields nothing usable — a renamed OU, a service account that lost read
on the user attribute, a filter that stopped matching. The only guard on the
destructive half of the sync was `err != nil`, so that answer was read as
"every user is gone" and the job detached every client from the configured
inbounds, once a minute, for as long as the directory stayed broken.

Gate auto-delete behind autoDeleteSafeForFetch: refuse an empty fetch, and
refuse one that collapsed below half of the last successful sync, which is a
misconfigured directory far more often than real churn.

Also stop splitCsv from defaulting an empty string to DefaultTruthyValues.
That default belongs to the truthy-value setting, but splitCsv is also what
parses ldapInboundTags, so an unconfigured tag list silently resolved to
["true","1","yes","on"]. It only ever bounded the blast radius by accident.
2026-08-01 15:18:50 +02:00
Sanaei 1ff90c5b66 docs(claude): bound comment length, fix size, and test value
Three agent-facing rules, each written after the same mistake showed up in
review.

Comments were banned outright, which the codebase itself contradicts on
almost every file — the ban pushed real invariants out of the code entirely.
Allow them, but cap a block at 2 lines and spend those lines on the *why* a
name cannot carry.

Add a scope rule: the fix must be the smallest change that removes the root
cause. A small bug does not earn new columns, jobs, abstractions or config;
if it genuinely needs architecture, agree on that first instead of shipping
it alongside the fix.

Add two testing rules: a test must go red when its fix is reverted, and it
must cover something that can actually break. A test that passes either way
certifies nothing and is then cited as proof the fix works.
2026-08-01 15:18:35 +02:00
dependabot[bot] 138e1bd840 chore(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.0 (#6162)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 18:30:43 +02:00
Isuru Sampath 31c1eed5dc fix dead code, typo, and minor bugs in main.go, process.go and index.go (#6167)
Fixes several small issues found during code review:
- fix(xray): return explicit nil instead of stale err in getLogPath
- fix(xray): remove duplicate doc comment on GetErrorLogPath
- refactor: remove unreachable return after log.Fatalf (×4)
- fix(cli): add missing newline to listen IP success message
- fix(cli): typo "form" → "from" in migrate help text
- refactor: simplify var+assign to short declaration for server/subServer
- fix(controller): return error from getTwoFactorEnable instead of swallowing it
2026-07-31 18:27:46 +02:00
PathGao 264f61eb90 Merge pull request #6161 from PathGao/feat-sidebar-pinning
feat(ui): let users pin the sidebar
2026-07-30 23:37:47 +08:00
PathGao ac584cfc90 fix(ui): reserve space for pinned sidebar
Keep page content accessible when the desktop sidebar remains expanded and cover the complete pin lifecycle.
2026-07-30 14:52:38 +08:00
PathGao 91c5d7b19f style(ui): preserve sidebar header spacing
Keep the original title alignment while fitting the pin with the existing header actions.
2026-07-30 14:48:03 +08:00
PathGao b2fe233108 fix(ui): align sidebar pin controls
Keep the pin with the expanded header actions and center the collapsed version link with the navigation rail.
2026-07-30 14:46:43 +08:00
PathGao 5373786faa feat(ui): let users pin the sidebar
Restore a persistent expanded-sidebar choice while preserving the compact hover rail as the default.
2026-07-30 14:39:55 +08:00
68 changed files with 3071 additions and 1691 deletions
+12 -3
View File
@@ -29,10 +29,19 @@
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui",
"XUI_DB_TYPE": "postgres",
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable",
"PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable"
},
"windows": {
"env": {
"XUI_DEBUG": "true",
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui",
"XUI_DB_TYPE": "postgres",
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable",
"PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
}
},
"console": "integratedTerminal"
},
}
]
}
+9 -1
View File
@@ -8,9 +8,17 @@
"args": [
"build",
"-o",
"bin/3x-ui.exe",
"bin/3x-ui",
"./main.go"
],
"windows": {
"args": [
"build",
"-o",
"bin/3x-ui.exe",
"./main.go"
]
},
"options": {
"cwd": "${workspaceFolder}"
},
+71 -23
View File
@@ -12,8 +12,10 @@ file locations when it can answer in one hop.
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-multi` binary
(`github.com/mhsanaei/mtg-multi`, a multi-secret fork built from source;
`internal/mtproto/`) — outside Xray, one process per inbound serving each
(a multi-secret mtg fork — NOT a Go dependency; its prebuilt release binary is
fetched at image/release build time by `DockerInit.sh` + `release.yml`,
panel-side code in `internal/mtproto/`) — outside Xray, one process per inbound
serving each
client's FakeTLS secret via the fork's `[secrets]` section (plus per-client
ad-tags via `[secret-ad-tags]` and per-client data quota / expiry via
`[secret-limits]`, mapped from the client's `totalGB`/`expiryTime`). Client,
@@ -32,9 +34,9 @@ file locations when it can answer in one hop.
- `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/database/` + `internal/database/model/` — GORM schema (~24 models;
Inbound, Client, Setting, User are the core), 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-multi` binary.
- `internal/sub/` — subscription server (raw / JSON / Clash).
@@ -46,7 +48,8 @@ file locations when it can answer in one hop.
- `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).
- `job/` 17 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
`runtime/` (master/sub-node over mTLS), `websocket/`.
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
@@ -54,27 +57,51 @@ file locations when it can answer in one hop.
- `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`.
`frontend/scripts/build-openapi.mjs`. (`tools/seedperf/` is a separate seeding
/load helper.)
- `docs/` — separate Next.js/Fumadocs site (pnpm, own CI in `docs-ci.yml`,
outside `make verify`). Holds a THIRD independent implementation of
link/subscription generation in `docs/lib/xray/` — check it whenever
share-link or install-command output changes.
## 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.
- Fix size must match bug size. Find the root cause, then make the SMALLEST
change that removes it — a one-line guard beats a new subsystem. A small bug
does not earn new columns, jobs, abstractions, config knobs or helper layers.
If a fix genuinely needs new architecture, say so and get agreement first;
never ship it unasked next to the fix.
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
carry the meaning first and rename rather than annotate; spend the 2 lines on
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
constraint. 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.
`cd frontend && npm run gen`). Hand-maintained but pinned both ways by
`TestRouteRegistryContract` (`internal/web/routes_contract_test.go`): a missing
OR stale entry fails `make test-go`. Scope: `/panel/api/*` + a few session
routes; sub-server routes are exempt.
- 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.
- A new or renamed endpoint has a FOURTH step nothing checks: copy
`frontend/public/openapi.json` `docs/public/openapi.json`, then
`cd docs && pnpm gen:api` to refresh the MDX under
`docs/content/docs/en/reference/api/`. `docs-ci.yml` fires only on `docs/**`.
- A new English i18n key goes in EVERY locale JSON in `internal/web/translation/`
(13 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
`frontend/src/test/i18n-dead-keys.test.ts` fails both ways. It is a frontend
test, so run `npm test`, not just `make test-go`. At runtime the frontend falls
back to en-US; Go (`internal/web/locale/`) returns "" for an unknown key.
- 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.
- Every state-changing inbound/client op dispatches through `runtime.Runtime`
(`internal/web/runtime/`) — never straight to `internal/xray/api.go`, never from
a controller or cron job. A direct call passes every local test and silently
breaks every multi-node deployment. Other layering rules: `docs/architecture.md` §8.
- Conventional commits: `type(area): short imperative summary`, then a body
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
`docs`, `style`.
## Go conventions
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
@@ -83,13 +110,26 @@ file locations when it can answer in one hop.
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
`internal/sub`'s `initSubDB(t)` is the template.
- A test must fail without its fix. Write it, revert the fix, watch it go red,
restore. A test that passes either way is worse than no test: it certifies
nothing and then gets cited as proof the fix works.
- Test what can actually break. No test for a getter, a constant, a rename, a
pure map lookup, or inputs the function can never receive. One real test that
drives the bug through the actual code path beats five that restate the code.
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
green `go test ./...` does not mean those paths ran.
## 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/`.
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
a headless-Chromium Storybook project, so run
`npx playwright install --with-deps chromium` once or `make verify` fails.
- 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
@@ -99,15 +139,23 @@ file locations when it can answer in one hop.
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:
A fresh clone has no `internal/web/dist/`, so a bare `go build ./...` dies with
`pattern all:dist: no matching files found` while ~35 other packages pass — it
reads as a broken repo, not a missing step. Run `make dist-stub` once; every
`make` Go target already depends on it, which is why `make test-go` beats
`go test ./...`. Run `make help` for all targets. The local gate:
make verify
make verify # gen-check + lint + typecheck + test + build + build-storybook
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when
you touch DB/dialect or parser code.
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.
1. `make verify` passes — its `gen-check` already runs `make gen` and fails on a
dirty `frontend/src/generated` / `frontend/public/openapi.json`.
2. Diff is focused; refactors are separate from feature work.
+4 -16
View File
@@ -1,30 +1,18 @@
import '../global.css';
import { RootProvider } from 'fumadocs-ui/provider/next';
import { Inter, Vazirmatn } from 'next/font/google';
import { i18n, localeDirection } from '@/lib/i18n';
import { i18n } from '@/lib/i18n';
import { provider } from '@/lib/i18n-ui';
import SearchDialog from '@/components/search-dialog';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
export default async function LangLayout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
const dir = localeDirection(lang);
const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
return (
<html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
<body className="flex min-h-screen flex-col" suppressHydrationWarning>
<RootProvider i18n={provider(lang)} search={{ SearchDialog }}>
{children}
</RootProvider>
</body>
</html>
<RootProvider i18n={provider(lang)} search={{ SearchDialog }} theme={{ enabled: false }}>
{children}
</RootProvider>
);
}
+30 -5
View File
@@ -1,10 +1,16 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Inter, Vazirmatn } from 'next/font/google';
import './global.css';
import { appName, appTagline, siteUrl } from '@/lib/shared';
import { i18n, localeDirection } from '@/lib/i18n';
// Global SEO defaults. The real <html>/<body> live in `app/[lang]/layout.tsx`
// so we can set `lang`/`dir` per locale (RTL for fa); this root layout is a
// pass-through that only carries site-wide metadata.
const inter = Inter({ subsets: ['latin'], display: 'swap' });
// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
// Global SEO defaults and document shell. Locale-aware html attributes are
// computed from route params so RTL locales get a correct base direction.
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
@@ -26,6 +32,25 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
export default async function RootLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang?: string }>;
}) {
const { lang: rawLang } = await params;
const lang = i18n.languages.includes(rawLang as (typeof i18n.languages)[number])
? (rawLang as (typeof i18n.languages)[number])
: i18n.defaultLanguage;
const dir = localeDirection(lang);
const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
return (
<html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
<body className="flex min-h-screen flex-col" suppressHydrationWarning>
{children}
</body>
</html>
);
}
+1 -1
View File
@@ -542,7 +542,7 @@ golangci-lint run # full lint (gofumpt + goimports formatting)
go run main.go # run the panel locally (serves embedded dist if built)
```
**Frontend (`cd frontend`, Node ≥ 22):**
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
```bash
npm install
npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
+4 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { create } from '@orama/orama';
import { create } from 'zbsearch';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static';
import {
@@ -25,8 +25,8 @@ interface SharedProps {
// default static dialog feeds those codes to Orama as a tokenizer language, but
// Orama only accepts full names ("english") and throws on "en" — which silently
// breaks search entirely. All docs content is English (other locales fall back
// to it), so re-create the dialog — the documented escape hatch for custom Orama
// setups — with an initOrama that always builds an English index.
// to it), so re-create the dialog — the documented escape hatch for custom search
// setups — with an initDB that always builds an English index.
export default function SearchDialogClient(props: SharedProps) {
const { locale } = useI18n();
const client = useMemo(
@@ -34,7 +34,7 @@ export default function SearchDialogClient(props: SharedProps) {
oramaStaticClient({
from: '/api/search',
locale,
initOrama: () => create({ schema: { _: 'string' }, language: 'english' }),
initDB: () => create({ schema: { _: 'string' }, language: 'english' }),
}),
[locale],
);
+104
View File
@@ -0,0 +1,104 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState, useSyncExternalStore } from 'react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
type ThemeMode = 'light-dark' | 'light-dark-system';
type ThemePref = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'docs-theme';
// `useSyncExternalStore` supplies the same value for SSR and hydration, then
// switches to the browser value after React has attached to the markup.
const subscribeToHydration = () => () => {};
const getHydrationClientSnapshot = () => true;
const getHydrationServerSnapshot = () => false;
function getStoredTheme(): ThemePref {
if (typeof window === 'undefined') return 'system';
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system';
}
function getResolvedTheme(theme: ThemePref): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme: ThemePref): void {
if (typeof document === 'undefined') return;
const resolved = getResolvedTheme(theme);
const root = document.documentElement;
root.classList.toggle('dark', resolved === 'dark');
root.style.colorScheme = resolved;
}
export function DocsThemeSwitch({
className,
mode = 'light-dark-system',
...props
}: {
className?: string;
mode?: ThemeMode;
} & Omit<ComponentProps<'div'>, 'children'>) {
// Keep the server and first client render identical. Reading localStorage or
// matchMedia here would make a persisted/system preference change the client
// markup before React has finished hydrating it.
const [selectedTheme, setSelectedTheme] = useState<ThemePref>('system');
const hydrated = useSyncExternalStore(
subscribeToHydration,
getHydrationClientSnapshot,
getHydrationServerSnapshot,
);
const theme = hydrated ? getStoredTheme() : selectedTheme;
useEffect(() => {
if (hydrated) applyTheme(theme);
}, [hydrated, theme]);
useEffect(() => {
if (!hydrated) return;
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => applyTheme('system');
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, [hydrated, theme]);
const resolved = hydrated ? getResolvedTheme(theme) : 'light';
const setTheme = (nextTheme: ThemePref) => {
window.localStorage.setItem(STORAGE_KEY, nextTheme);
applyTheme(nextTheme);
setSelectedTheme(nextTheme);
};
const nextTheme = () => {
if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
if (theme === 'light') return 'dark';
if (theme === 'dark') return 'system';
return resolved === 'dark' ? 'light' : 'dark';
};
const label =
mode === 'light-dark-system'
? `Switch theme (current: ${theme})`
: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} mode`;
return (
<div className={cn('inline-flex', className)} {...props}>
<button
type="button"
aria-label={label}
title={label}
onClick={() => setTheme(nextTheme())}
className="inline-flex size-8 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
>
{resolved === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
</button>
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Heart } from 'lucide-react';
import { Logo } from '@/components/logo';
import { TelegramIcon } from '@/components/icons';
import { DocsThemeSwitch } from '@/components/theme-switch';
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
import { getSiteMessages } from './site-i18n';
@@ -12,6 +13,9 @@ export function baseOptions(lang: string): BaseLayoutProps {
const m = getSiteMessages(lang);
return {
slots: {
themeSwitch: DocsThemeSwitch,
},
nav: {
title: (
<span className="inline-flex items-center gap-2 font-semibold">
+16 -14
View File
@@ -19,34 +19,36 @@
},
"dependencies": {
"@orama/orama": "^3.1.18",
"fumadocs-core": "^16.11.5",
"fumadocs-core": "^16.14.1",
"fumadocs-docgen": "^3.1.0",
"fumadocs-mdx": "^15.2.0",
"fumadocs-openapi": "^11.2.2",
"fumadocs-ui": "^16.11.5",
"lucide-react": "^1.25.0",
"mermaid": "^11.16.0",
"next": "16.2.11",
"fumadocs-mdx": "^15.2.2",
"fumadocs-openapi": "^11.2.3",
"fumadocs-ui": "^16.14.1",
"lucide-react": "^1.29.0",
"mermaid": "^11.16.1",
"next": "16.3.0",
"next-themes": "^0.4.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-qr-code": "^2.2.0",
"tailwind-merge": "^3.6.0",
"zbsearch": "3.3.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^9.39.5",
"eslint-config-next": "16.2.11",
"postcss": "^8.5.21",
"eslint-config-next": "16.3.0",
"eslint-plugin-react": "^7.37.5",
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript": "6.0.3",
"vitest": "^4.1.10"
},
"packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
"packageManager": "pnpm@11.20.0"
}
+691 -548
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -10,3 +10,7 @@ overrides:
minimumReleaseAgeExclude:
- '@mermaid-js/parser@1.2.0'
- mermaid@11.16.0
- fumadocs-core@16.14.1
- fumadocs-ui@16.14.1
- lucide-react@1.29.0
- postcss@8.5.26
+912 -879
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -30,52 +30,52 @@
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.5.7",
"@noble/hashes": "^2.2.0",
"@hookform/resolvers": "^5.7.1",
"@noble/hashes": "^2.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.5.2",
"antd": "^6.6.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.6",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"persian-calendar-suite": "^1.5.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.83.0",
"react-hook-form": "^7.85.0",
"react-i18next": "^17.0.11",
"react-router": "^8.3.0",
"swagger-ui-react": "^5.32.11",
"swagger-ui-react": "^5.32.13",
"uplot": "^1.6.32",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/addon-vitest": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@storybook/addon-a11y": "^10.5.7",
"@storybook/addon-docs": "^10.5.7",
"@storybook/addon-vitest": "^10.5.7",
"@storybook/react-vite": "^10.5.7",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.4",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.8.0",
"globals": "^17.11.0",
"husky": "^9.1.7",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"lint-staged": "^17.3.0",
"msw": "^2.15.0",
"playwright": "^1.62.0",
"storybook": "^10.5.5",
"playwright": "^1.62.1",
"storybook": "^10.5.7",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
"typescript-eslint": "^8.67.0",
"vite": "8.2.1",
"vitest": "^4.1.10"
},
"overrides": {
@@ -89,7 +89,8 @@
"react": "^19.0.0"
},
"swagger-ui-react": {
"js-yaml": "^4.2.0"
"js-yaml": "^4.2.0",
"brace-expansion": "^5.0.9"
},
"@typeschema/valibot": {
"valibot": "^1.1.0"
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { AllSettingResponseSchema, AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
@@ -17,7 +17,7 @@ type SettingSaveResult = {
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
const validated = parseMsg(msg, AllSettingResponseSchema, 'setting/all');
return validated.obj;
}
+43 -2
View File
@@ -12,7 +12,7 @@
align-self: flex-start;
}
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
.ant-sidebar:not(.sidebar-pinned) > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
}
@@ -53,10 +53,18 @@
.brand-actions {
display: inline-flex;
align-items: center;
gap: 2px;
gap: 0;
flex-shrink: 0;
}
.brand-actions .sidebar-pin,
.brand-actions .sidebar-docs,
.brand-actions .sidebar-donate,
.brand-actions .sidebar-theme-cycle {
width: 26px;
height: 26px;
}
.sidebar-donate {
background: transparent;
border: none;
@@ -230,6 +238,34 @@
padding: 8px 8px 12px;
}
.sidebar-pin {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--ant-color-text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-pin:hover,
.sidebar-pin:focus-visible {
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
color: var(--ant-color-primary);
transform: scale(1.08);
outline: none;
}
.sidebar-pin .anticon {
font-size: 16px;
}
.sider-version {
display: flex;
align-items: center;
@@ -245,6 +281,11 @@
transition: color 0.2s;
}
.ant-layout-sider-collapsed .sider-version {
justify-content: center;
padding: 8px 0;
}
.sider-version .anticon {
font-size: 16px;
}
+42 -4
View File
@@ -23,6 +23,8 @@ import {
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
PushpinOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -44,7 +46,8 @@ const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
const SIDER_WIDTH = 220;
const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
let hoveredAcrossRemounts = false;
@@ -135,6 +138,20 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
);
}
function readSidebarPinned() {
try {
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
} catch {
return false;
}
}
function saveSidebarPinned(pinned: boolean) {
try {
localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
} catch {}
}
export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
@@ -144,8 +161,13 @@ export default function AppSidebar() {
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered;
const railCollapsed = !hovered && !pinned;
const railStyle = useMemo(
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
[pinned],
);
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
@@ -153,6 +175,12 @@ export default function AppSidebar() {
setHovered(value);
}, []);
const togglePinned = useCallback(() => {
const next = !pinned;
saveSidebarPinned(next);
setPinned(next);
}, [pinned]);
useEffect(() => {
const timer = window.setTimeout(() => {
const el = rootRef.current;
@@ -261,14 +289,14 @@ export default function AppSidebar() {
return (
<div
ref={rootRef}
className="ant-sidebar"
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={220}
width={SIDER_WIDTH}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
@@ -278,6 +306,16 @@ export default function AppSidebar() {
</div>
{!railCollapsed && (
<div className="brand-actions">
<button
type="button"
className="sidebar-pin"
aria-label={t('menu.pinSidebar')}
aria-pressed={pinned}
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned}
>
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
</button>
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
+9
View File
@@ -104,6 +104,15 @@ export const AllSettingSchema = z.object({
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
// Existing installations can contain regex values saved before the backend
// enforced its 2,048-character limit. Accept those values when reading so the
// settings page can display and let users correct them, while keeping the
// stricter schema above for outgoing updates.
export const AllSettingResponseSchema = AllSettingSchema.extend({
subJsonUserAgentRegex: z.string().optional(),
subClashUserAgentRegex: z.string().optional(),
});
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
+67
View File
@@ -0,0 +1,67 @@
import { fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, test, vi } from 'vitest';
import AppSidebar from '@/layouts/AppSidebar';
import { renderWithProviders } from './test-utils';
vi.mock('@/api/queries/useAllSettings', () => ({
useAllSettings: () => ({ allSetting: {} }),
}));
afterEach(() => {
localStorage.clear();
});
function renderSidebar() {
return renderWithProviders(
<MemoryRouter>
<AppSidebar />
</MemoryRouter>,
);
}
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
const first = renderSidebar();
const sidebar = first.container.querySelector('.ant-layout-sider');
const sidebarRoot = first.container.querySelector('.ant-sidebar');
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
fireEvent.mouseEnter(sidebarRoot!);
const pinButton = screen.getByRole('button', { name: 'Pin sidebar' });
expect(pinButton.closest('.brand-actions')).not.toBeNull();
fireEvent.click(pinButton);
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(localStorage.getItem('sidebar-pinned')).toBe('true');
first.unmount();
const second = renderSidebar();
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(restoredSidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
});
test('returns to the compact rail after unpinning', () => {
const view = renderSidebar();
const sidebar = view.container.querySelector('.ant-layout-sider');
const sidebarRoot = view.container.querySelector('.ant-sidebar');
fireEvent.mouseEnter(sidebarRoot!);
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
});
@@ -99,7 +99,11 @@ describe('useClients query gating', () => {
});
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
true,
'',
url.includes('/inbounds/options') ? [] : emptyPage,
));
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+3
View File
@@ -35,6 +35,7 @@ const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, stat
describe('HttpUtil', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
it('unwraps a success envelope and shows a success toast', async () => {
@@ -80,6 +81,7 @@ describe('HttpUtil', () => {
expect(msg.success).toBe(false);
expect(msg.msg).toBe('bad input');
expect(console.error).not.toHaveBeenCalled();
});
it('maps a thrown native error to a failure Msg via its message', async () => {
@@ -88,6 +90,7 @@ describe('HttpUtil', () => {
const msg = await HttpUtil.get('/x', undefined, { silent: true });
expect(msg.msg).toBe('Network down');
expect(console.error).not.toHaveBeenCalled();
});
it('returns "No response data" for an empty body', async () => {
+16 -3
View File
@@ -48,6 +48,16 @@ if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// jsdom does not implement pseudo-element styles or Range geometry. Ant
// Design and CodeMirror use these APIs for layout, so supply harmless test
// fallbacks instead of emitting noisy "Not implemented" errors.
const nativeGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((element: Element) => nativeGetComputedStyle(element)) as typeof window.getComputedStyle;
if (!Range.prototype.getClientRects) {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
}
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: 'en-US',
@@ -75,9 +85,12 @@ afterEach(async () => {
}
});
import { HttpUtil } from '@/utils';
import { HttpUtil, Msg } from '@/utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
true,
'',
url.includes('/panel/api/inbounds/options') ? [] : {},
));
+3 -1
View File
@@ -13,9 +13,10 @@ afterEach(() => {
});
describe('useAllSettings', () => {
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
it('accepts legacy overlength regex settings without logging a response validation warning', async () => {
const subJsonUserAgentRegex = 'x'.repeat(2_049);
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
@@ -25,6 +26,7 @@ describe('useAllSettings', () => {
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
expect(warning).not.toHaveBeenCalled();
});
it('keeps an edited setting when a refetch returns older server data', async () => {
+15
View File
@@ -13,10 +13,15 @@ afterEach(() => {
describe('parseMsg', () => {
it('rejects a successful response whose payload violates its schema', () => {
const msg = new Msg(true, '', { id: 'not-a-number' });
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
'test/value response failed validation',
);
expect(warning).toHaveBeenCalledWith(
'[zod] test/value response failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['id'] })]),
);
});
it('preserves a missing successful payload for callers that handle empty values', () => {
@@ -25,17 +30,27 @@ describe('parseMsg', () => {
it('rejects malformed paged-client payloads', () => {
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
'clients/list/paged response failed validation',
);
expect(warning).toHaveBeenCalledWith(
'[zod] clients/list/paged response failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['total'] })]),
);
});
});
describe('fetchXrayConfig', () => {
it('keeps a malformed xray payload available for repair', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
expect(warning).toHaveBeenCalledWith(
'[zod] xray/ config payload failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] })]),
);
});
});
+8 -4
View File
@@ -75,11 +75,13 @@ export class HttpUtil {
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('GET request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
if (!silent) {
console.error('GET request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
@@ -92,11 +94,13 @@ export class HttpUtil {
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('POST request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
if (!silent) {
console.error('POST request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
+61 -9
View File
@@ -4,7 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
const outDir = path.resolve(__dirname, '../internal/web/dist');
const outDir = path.resolve(import.meta.dirname, '../internal/web/dist');
const BACKEND_TARGET = 'http://localhost:2053';
function resolveDBPath() {
@@ -12,12 +12,12 @@ function resolveDBPath() {
if (envFolder) {
const abs = path.isAbsolute(envFolder)
? envFolder
: path.resolve(__dirname, '..', envFolder);
: path.resolve(import.meta.dirname, '..', envFolder);
return path.join(abs, 'x-ui.db');
}
const repoSubDB = path.resolve(__dirname, '..', 'x-ui', 'x-ui.db');
const repoSubDB = path.resolve(import.meta.dirname, '..', 'x-ui', 'x-ui.db');
if (fs.existsSync(repoSubDB)) return repoSubDB;
const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
const repoDB = path.resolve(import.meta.dirname, '..', 'x-ui.db');
if (fs.existsSync(repoDB)) return repoDB;
return '/etc/x-ui/x-ui.db';
}
@@ -54,7 +54,7 @@ function refreshBasePath() {
function readPanelVersion() {
try {
const versionFile = path.resolve(__dirname, '..', 'config', 'version');
const versionFile = path.resolve(import.meta.dirname, '..', 'config', 'version');
return fs.readFileSync(versionFile, 'utf8').trim();
} catch (_e) {
return '';
@@ -151,11 +151,62 @@ function makeBackendProxy(target) {
};
}
// Deps only reachable through swagger-ui-react (verified via `npm ls`). The
// catch-all `vendor` chunk would otherwise eager-load them on first paint,
// although the api-docs page is the only lazy route importing them.
const SWAGGER_ONLY_DEPS = [
'@babel/runtime-corejs3',
'@scarf/scarf',
'@swagger-api/apidom-',
'@swaggerexpert/',
'base64-js',
'buffer',
'classnames',
'css.escape',
'deep-extend',
'dompurify',
'fast-json-patch',
'highlight.js',
'highlightjs-vue',
'ieee754',
'immutable',
'js-file-download',
'js-yaml',
'lodash',
'lowlight',
'neotraverse',
'node-abort-controller',
'openapi-path-templating',
'openapi-server-url-templating',
'prismjs',
'prop-types',
'ramda',
'ramda-adjunct',
'randexp',
'react-copy-to-clipboard',
'react-debounce-input',
'react-immutable-proptypes',
'react-immutable-pure-component',
'react-inspector',
'react-redux',
'react-syntax-highlighter',
'redux',
'redux-immutable',
'remarkable',
'reselect',
'serialize-error',
'sha.js',
'url-parse',
'xml',
'xml-but-prettier',
'zenscroll',
];
export default defineConfig({
plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@': path.resolve(import.meta.dirname, 'src'),
},
},
experimental: {
@@ -182,9 +233,9 @@ export default defineConfig({
chunkSizeWarningLimit: 1500,
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, 'login.html'),
subpage: path.resolve(__dirname, 'subpage.html'),
index: path.resolve(import.meta.dirname, 'index.html'),
login: path.resolve(import.meta.dirname, 'login.html'),
subpage: path.resolve(import.meta.dirname, 'subpage.html'),
},
output: {
manualChunks(id) {
@@ -222,6 +273,7 @@ export default defineConfig({
id.includes('/node_modules/swagger-ui-react/')
|| id.includes('/node_modules/swagger-ui/')
|| id.includes('/node_modules/swagger-client/')
|| SWAGGER_ONLY_DEPS.some((dep) => id.includes(`/node_modules/${dep}/`))
) return 'vendor-swagger';
if (id.includes('/node_modules/uplot/')) return 'vendor-uplot';
if (id.includes('dayjs')) return 'vendor-dayjs';
+3 -2
View File
@@ -1,12 +1,11 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const dirname = import.meta.dirname;
export default defineConfig({
plugins: [react()],
@@ -17,6 +16,8 @@ export default defineConfig({
},
test: {
globals: false,
// Keep jsdom-heavy form tests within the memory budget of local and CI runners.
maxWorkers: 2,
projects: [
{
extends: true,
+8 -8
View File
@@ -18,7 +18,7 @@ require (
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.6
github.com/shirou/gopsutil/v4 v4.26.7
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.73.0
github.com/xlzd/gotp v0.1.0
@@ -27,9 +27,9 @@ require (
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
google.golang.org/grpc v1.82.1
google.golang.org/grpc v1.83.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.0
gorm.io/driver/postgres v1.6.2
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
pgregory.net/rapid v1.3.0
@@ -41,7 +41,7 @@ require (
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
@@ -67,7 +67,7 @@ require (
github.com/klauspost/compress v1.19.1
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -76,7 +76,7 @@ require (
github.com/pion/dtls/v3 v3.1.5 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/transport/v4 v4.1.0 // indirect
github.com/pires/go-proxyproto v0.15.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
@@ -88,7 +88,7 @@ require (
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
@@ -108,7 +108,7 @@ require (
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/protobuf v1.36.11
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
+26 -26
View File
@@ -12,8 +12,8 @@ github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
@@ -125,8 +125,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c=
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
@@ -154,8 +154,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -178,8 +178,8 @@ github.com/sagernet/sing v0.8.11 h1:AKZRvjFPHtAXwGCjOJrzAQPiZxr8mobhuSUqkHf+VQw=
github.com/sagernet/sing v0.8.11/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -200,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
@@ -228,16 +228,16 @@ go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4Dh
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -279,10 +279,10 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -295,8 +295,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+12
View File
@@ -140,6 +140,9 @@ func initModels() error {
if err := migrateTgIDIndex(); err != nil {
return err
}
if err := migrateSyncOrphanColumns(); err != nil {
return err
}
if IsPostgres() {
if err := resyncPostgresSequences(db, models); err != nil {
log.Printf("Error resyncing postgres sequences: %v", err)
@@ -297,6 +300,15 @@ func rebuildInboundsWithoutInlineUniquePort() error {
})
}
// AutoMigrate adds the column; this only backfills the NULLs an older SQLite
// ALTER TABLE leaves behind, so the reaper's predicate never compares to NULL.
func migrateSyncOrphanColumns() error {
if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") {
return nil
}
return db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error
}
func migrateHostVerifyPeerCertByNameColumn() error {
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
return nil
+3
View File
@@ -911,6 +911,9 @@ type ClientRecord struct {
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
// deleting; orphans from any other cause stay at zero and are never reaped.
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
}
func (ClientRecord) TableName() string { return "clients" }
+1 -3
View File
@@ -163,7 +163,5 @@ func (a *IndexController) csrfToken(c *gin.Context) {
// getTwoFactorEnable retrieves the current status of two-factor authentication.
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
status, err := a.settingService.GetTwoFactorEnable()
if err == nil {
jsonObj(c, status, nil)
}
jsonObj(c, status, err)
}
@@ -0,0 +1,34 @@
package job
import "testing"
// A bind that succeeds with an unusable search returns (empty, nil); the only
// guard was `err != nil`, so that answer detached the entire inbound.
func TestAutoDeleteAllowed(t *testing.T) {
cases := []struct {
name string
previous int64
fetched int
want bool
}{
{"empty fetch on a fresh job is refused", 0, 0, false},
{"empty fetch after a healthy sync is refused", 500, 0, false},
{"full fetch on a fresh job is allowed", 0, 500, true},
{"steady fetch is allowed", 500, 500, true},
{"growth is allowed", 500, 900, true},
{"shrink above the retention floor is allowed", 500, 250, true},
{"shrink below the retention floor is refused", 500, 249, false},
{"collapse to a single user is refused", 500, 1, false},
{"single user with no history is allowed", 0, 1, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
j := NewLdapSyncJob()
j.lastFlagCount.Store(c.previous)
if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want {
t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v",
c.previous, c.fetched, got, c.want)
}
})
}
}
+33 -3
View File
@@ -2,6 +2,7 @@ package job
import (
"strings"
"sync/atomic"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -12,11 +13,16 @@ import (
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
// Share of the previous successful fetch a new one must still return to be
// trusted: a sudden collapse means a broken directory far more often than churn.
const ldapAutoDeleteMinRetainPercent = 50
type LdapSyncJob struct {
settingService service.SettingService
inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService
lastFlagCount atomic.Int64
}
// --- Helper functions for mustGet ---
@@ -77,7 +83,7 @@ func (j *LdapSyncJob) Run() {
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
TruthyVals: truthyValuesOrDefault(mustGetString(j.settingService.GetLdapTruthyValues)),
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
}
@@ -157,7 +163,7 @@ func (j *LdapSyncJob) Run() {
// --- Auto delete clients not in LDAP ---
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
if autoDelete {
if autoDelete && j.autoDeleteSafeForFetch(len(flags)) {
ldapEmailSet := map[string]struct{}{}
for e := range flags {
ldapEmailSet[e] = struct{}{}
@@ -166,11 +172,35 @@ func (j *LdapSyncJob) Run() {
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
}
}
j.lastFlagCount.Store(int64(len(flags)))
}
// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search
// yields nothing — a renamed OU, a lost read grant — which is not "all gone".
func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool {
if fetched == 0 {
logger.Warning("LDAP auto-delete skipped: directory returned no usable users")
return false
}
previous := j.lastFlagCount.Load()
if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent {
logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)",
fetched, previous, ldapAutoDeleteMinRetainPercent)
return false
}
return true
}
func truthyValuesOrDefault(s string) []string {
if vals := splitCsv(s); len(vals) > 0 {
return vals
}
return DefaultTruthyValues
}
func splitCsv(s string) []string {
if s == "" {
return DefaultTruthyValues
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
+27
View File
@@ -0,0 +1,27 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only
// soft-orphaned, once enough clean merges have agreed they are really gone.
type ReapSyncOrphansJob struct {
clientService service.ClientService
}
func NewReapSyncOrphansJob() *ReapSyncOrphansJob {
return new(ReapSyncOrphansJob)
}
func (j *ReapSyncOrphansJob) Run() {
reaped, err := j.clientService.ReapSyncOrphans()
if err != nil {
logger.Warning("reap sync orphans failed:", err)
return
}
if reaped > 0 {
logger.Infof("reap sync orphans: removed %d client(s)", reaped)
}
}
+4
View File
@@ -835,13 +835,16 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
successEmails := make([]string, 0, len(recordsByEmail))
successIds := make([]int, 0, len(recordsByEmail))
failedEmails := make([]string, 0, len(recordsByEmail))
for email, rec := range recordsByEmail {
if _, skipped := skippedReasons[email]; skipped {
failedEmails = append(failedEmails, email)
continue
}
successEmails = append(successEmails, email)
successIds = append(successIds, rec.Id)
}
withdrawClientTombstones(failedEmails...)
if len(successIds) > 0 {
// Serialize the row cleanup against the traffic poll to avoid the
@@ -875,6 +878,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
}
return nil
}); err != nil {
withdrawClientTombstones(successEmails...)
return result, needRestart, err
}
}
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"time"
"unicode"
"github.com/google/uuid"
@@ -21,7 +22,7 @@ import (
func hasForbiddenClientChar(s string) bool {
for _, r := range s {
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
return true
}
}
@@ -523,6 +524,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
withdrawClientTombstones(existing.Email)
return false, err
}
@@ -560,7 +562,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
}
// A failed inbound still holds the client in its settings JSON: keep the
// record so the next delete retries exactly the leftovers, and report it.
// The tombstone lifts with it, or the next node merge finishes the deletion.
if len(delErrs) > 0 {
withdrawClientTombstones(existing.Email)
return needRestart, errors.Join(delErrs...)
}
@@ -593,6 +597,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
}
return tx.Delete(&model.ClientRecord{}, id).Error
}); err != nil {
withdrawClientTombstones(existing.Email)
return needRestart, err
}
return needRestart, nil
@@ -0,0 +1,44 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Delete tombstones up front and keeps the record when an inbound fails. A
// surviving tombstone lets the next node merge finish the refused deletion.
func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
db := database.GetDB()
const email = "retry@x"
broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`)
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil {
t.Fatalf("attach client: %v", err)
}
t.Cleanup(func() { withdrawClientTombstones(email) })
if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil {
t.Fatal("setup: delete was expected to fail on the unparseable inbound settings")
}
var surviving int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if surviving != 1 {
t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving)
}
if isClientEmailTombstoned(email) {
t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
}
}
+3
View File
@@ -115,6 +115,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
}
incoming := clients[i].ToRecord()
// ToRecord copies the raw email; store the trimmed key this function
// looks up by, or a padded email is inserted and never found again.
incoming.Email = email
row, ok := existing[email]
if !ok {
if _, dup := pending[email]; !dup {
+13
View File
@@ -105,6 +105,19 @@ func tombstoneClientEmail(email string) {
}
}
func withdrawClientTombstones(emails ...string) {
if len(emails) == 0 {
return
}
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
for _, email := range emails {
if email != "" {
delete(recentlyDeleted, email)
}
}
}
func tombstoneClientEmails(emails []string) {
if len(emails) == 0 {
return
@@ -0,0 +1,85 @@
package service
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// How long a client stays recoverable after a node merge concluded it is gone.
// Any merge that sees it attached again inside this window clears the mark.
const syncOrphanReapGrace = 15 * time.Minute
// A traffic row with no clients row behind it is drift the orphan sweep will
// never mark, so it stays on the old delete-immediately path.
func clientRecordExists(tx *gorm.DB, email string) bool {
var n int64
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
return false
}
return n > 0
}
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
if email == "" {
return nil
}
return tx.Model(&model.ClientRecord{}).
Where("email = ? AND sync_orphaned_at = 0", email).
Update("sync_orphaned_at", nowMs).Error
}
// A client that is attached again is not orphaned, whatever an earlier merge
// concluded — this is what makes a bad merge recoverable instead of fatal.
func clearSyncOrphanMarks(tx *gorm.DB) error {
return tx.Model(&model.ClientRecord{}).
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
Update("sync_orphaned_at", 0).Error
}
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
func (s *ClientService) ReapSyncOrphans() (int, error) {
db := database.GetDB()
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
var emails []string
if err := db.Model(&model.ClientRecord{}).
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
Pluck("email", &emails).Error; err != nil {
return 0, err
}
if len(emails) == 0 {
return 0, nil
}
reaped := 0
for _, batch := range chunkStrings(emails, sqlInChunk) {
if err := runSerializedTx(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
}); err != nil {
return reaped, err
}
reaped += len(batch)
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
}
return reaped, nil
}
@@ -0,0 +1,148 @@
package service
import (
"fmt"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
t.Helper()
var row model.ClientRecord
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("read client %q: %v", email, err)
}
return row.SyncOrphanedAt
}
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
t.Helper()
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
if err := db.Model(&model.ClientRecord{}).
Where("email = ?", email).
Update("sync_orphaned_at", past).Error; err != nil {
t.Fatalf("backdate orphan mark: %v", err)
}
}
// The merge must soft-orphan, not delete: everything stays recoverable until
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
clientSvc := &ClientService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "gone@x"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
t.Fatalf("orphaning merge: %v", err)
}
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
if readOrphanMark(t, db, email) <= 0 {
t.Fatal("merge did not stamp sync_orphaned_at")
}
reaped, err := clientSvc.ReapSyncOrphans()
if err != nil {
t.Fatalf("reap inside grace: %v", err)
}
if reaped != 0 {
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
}
if rec, _ := countClientRows(t, db, email); rec != 1 {
t.Fatal("client removed before the grace period elapsed")
}
backdateOrphanMark(t, db, email)
reaped, err = clientSvc.ReapSyncOrphans()
if err != nil {
t.Fatalf("reap after grace: %v", err)
}
if reaped != 1 {
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
}
rec, traf := countClientRows(t, db, email)
if rec != 0 || traf != 0 {
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
}
}
// A client the node reports again was never gone: clearing the mark is what
// turns a bad merge into a recoverable blip instead of a delayed deletion.
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
clientSvc := &ClientService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "flaky@x"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
t.Fatalf("orphaning merge: %v", err)
}
if readOrphanMark(t, db, email) <= 0 {
t.Fatal("setup: expected the merge to mark the client")
}
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
}
backdateOrphanMark(t, db, email)
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
}
}
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
// mark and keep their existing manual-cleanup semantics.
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
db := initTrafficTestDB(t)
clientSvc := &ClientService{}
const email = "manual@x"
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
reaped, err := clientSvc.ReapSyncOrphans()
if err != nil {
t.Fatalf("reap: %v", err)
}
if reaped != 0 {
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
}
var surviving int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if surviving != 1 {
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
}
}
@@ -76,6 +76,43 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
}
}
// The IP-limit job keys its tracking rows on the casing Xray reports, so an
// inbound whose settings JSON drifted in case leaves a row under each spelling.
func TestUpdateInboundClientCaseOnlyRenameSurvivesExistingClientIpsRow(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{{Email: "Sanaei", ID: "aaaaaaaa-0000-0000-0000-000000000009", SubID: "sub-ips", Enable: true}}
ib := mkInbound(t, 22011, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
for _, email := range []string{"Sanaei", "sanaei"} {
row := &model.InboundClientIps{ClientEmail: email, Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
if err := database.GetDB().Create(row).Error; err != nil {
t.Fatalf("seed client ips for %q: %v", email, err)
}
}
lowered := source
lowered[0].Email = "sanaei"
if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
Id: ib.Id,
Settings: clientsSettings(t, lowered),
}, "sanaei"); err != nil {
t.Fatalf("UpdateInboundClient with a colliding client ips row: %v", err)
}
var rows []model.InboundClientIps
if err := database.GetDB().Find(&rows).Error; err != nil {
t.Fatalf("read client ips: %v", err)
}
if len(rows) != 1 || rows[0].ClientEmail != "sanaei" {
t.Fatalf("client ips rows after rename = %+v, want a single row for %q", rows, "sanaei")
}
}
func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
+71 -57
View File
@@ -1078,7 +1078,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
payload := inbound
pushable := true
if inbound.Protocol == model.MTProto {
if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
payload = built
} else {
logger.Debug("Unable to prepare runtime inbound config:", bErr)
@@ -1326,7 +1326,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil
}
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
runtimeInbound, err := s.buildInboundForLocalRuntime(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
@@ -1511,7 +1511,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
payload := oldInbound
pushable := true
if inbound.Enable {
if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
if built, err2 := s.buildInboundForLocalRuntime(tx, oldInbound); err2 == nil {
payload = built
} else {
logger.Debug("Unable to prepare runtime inbound config:", err2)
@@ -1537,7 +1537,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
var runtimeInbound *model.Inbound
if inbound.Enable {
var err2 error
runtimeInbound, err2 = s.buildRuntimeInboundForAPI(tx, oldInbound)
runtimeInbound, err2 = s.buildInboundForLocalRuntime(tx, oldInbound)
if err2 != nil {
logger.Debug("Unable to prepare runtime inbound config:", err2)
needRestart = true
@@ -1608,81 +1608,95 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return inbound, needRestart, nil
}
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
// A node mirrors this payload into its own DB, so every client must survive:
// filtering one out makes the node delete it, and the master then mirrors that.
func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
if inbound == nil {
return nil, fmt.Errorf("inbound is nil")
}
runtimeInbound := *inbound
built := *inbound
settings := map[string]any{}
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
return nil, err
}
mutated := false
if clients, ok := settings["clients"].([]any); ok {
var clientStats []xray.ClientTraffic
err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", inbound.Id).
Select("email", "enable").
Find(&clientStats).Error
if err != nil {
return nil, err
}
if !inboundCanHostFallbacks(inbound) {
return &built, nil
}
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
if err != nil {
return nil, err
}
if len(fallbacks) == 0 {
return &built, nil
}
generic := make([]any, 0, len(fallbacks))
for _, f := range fallbacks {
generic = append(generic, f)
}
settings["fallbacks"] = generic
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
if mErr != nil {
return nil, mErr
}
built.Settings = string(modifiedSettings)
return &built, nil
}
finalClients := make([]any, 0, len(clients))
for _, client := range clients {
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
if enable, exists := enableMap[email]; exists && !enable {
continue
}
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
finalClients = append(finalClients, c)
}
settings["clients"] = finalClients
mutated = true
// Strips disabled clients on top of the node payload. Safe only because the
// target here is an in-memory Xray/mtg config, not another panel's database.
func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
built, err := s.buildInboundForNodePush(tx, inbound)
if err != nil {
return nil, err
}
if inboundCanHostFallbacks(inbound) {
fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
if fbErr != nil {
return nil, fbErr
}
if len(fallbacks) > 0 {
generic := make([]any, 0, len(fallbacks))
for _, f := range fallbacks {
generic = append(generic, f)
}
settings["fallbacks"] = generic
mutated = true
}
settings := map[string]any{}
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
return nil, err
}
clients, ok := settings["clients"].([]any)
if !ok {
return built, nil
}
if !mutated {
return &runtimeInbound, nil
var clientStats []xray.ClientTraffic
if err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", built.Id).
Select("email", "enable").
Find(&clientStats).Error; err != nil {
return nil, err
}
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
finalClients := make([]any, 0, len(clients))
for _, client := range clients {
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
if enable, exists := enableMap[email]; exists && !enable {
continue
}
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
finalClients = append(finalClients, c)
}
settings["clients"] = finalClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return nil, err
}
runtimeInbound := *built
runtimeInbound.Settings = string(modifiedSettings)
return &runtimeInbound, nil
}
@@ -152,6 +152,13 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
}
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
// The caller only renames onto a free identity, so a row already sitting on
// newEmail is stale tracking data — drop it instead of failing the edit.
if oldEmail != newEmail {
if err := tx.Where("client_email = ?", newEmail).Delete(model.InboundClientIps{}).Error; err != nil {
return err
}
}
return tx.Model(model.InboundClientIps{}).Where("client_email = ?", oldEmail).Update("client_email", newEmail).Error
}
@@ -31,9 +31,9 @@ func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
t.Fatalf("seed fallback: %v", err)
}
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
runtimeIb, err := svc.buildInboundForLocalRuntime(db, master)
if err != nil {
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
var settings map[string]any
@@ -68,9 +68,9 @@ func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
t.Fatalf("seed fallback: %v", err)
}
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
runtimeIb, err := svc.buildInboundForLocalRuntime(db, ib)
if err != nil {
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
if strings.Contains(runtimeIb.Settings, "fallbacks") {
t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
+2 -2
View File
@@ -14,7 +14,7 @@ import (
// running: one instance per enabled local mtproto inbound, serving only the
// secrets of clients that are both enabled in the inbound settings and not
// depletion-disabled in client_traffics. That is the same effective client set
// buildRuntimeInboundForAPI pushes on interactive edits, so the reconcile job
// buildInboundForLocalRuntime pushes on interactive edits, so the reconcile job
// and the push paths agree on one fingerprint — a disagreement would surface
// as a needless mtg restart, and a job that read only the raw settings would
// keep serving depleted clients until an unrelated restart. Inbounds whose
@@ -95,7 +95,7 @@ func (s *InboundService) applyLocalMtproto(inboundId int) {
}
payload := inbound
if inbound.Enable {
if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
payload = built
}
}
@@ -55,9 +55,9 @@ func TestDesiredMtprotoInstancesFiltersDepleted(t *testing.T) {
})
t.Run("matchesInteractivePushFiltering", func(t *testing.T) {
built, err := svc.buildRuntimeInboundForAPI(database.GetDB(), served)
built, err := svc.buildInboundForLocalRuntime(database.GetDB(), served)
if err != nil {
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
pushInst, ok := mtproto.InstanceFromInbound(built)
if !ok {
+29 -14
View File
@@ -125,11 +125,8 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
}
}
}
// Reconcile with the same runtime-built payload interactive pushes
// send (disabled clients filtered, settings.fallbacks injected) so
// fingerprints line up and fallback edits actually reach the node.
runtimeIb := ib
if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
runtimeIb = built
}
if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
@@ -352,7 +349,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// origin (an inbound the node forwards from its own sub-node) is kept as-is,
// so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3.
var nodeRow model.Node
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
db.Select("guid", "config_dirty", "inbound_sync_mode", "inbound_tags").Where("id = ?", nodeID).First(&nodeRow)
// Re-read inside the serialized writer: a client added while this snapshot
// was in flight marks the node dirty after the caller sampled the flag.
dirty = dirty || nodeRow.ConfigDirty
nodeRow.Id = nodeID
unmanagedTag := unmanagedTagPredicate(&nodeRow)
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
originGuidFor := func(snapIb *model.Inbound) string {
@@ -671,6 +673,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if _, kept := snapTags[c.Tag]; kept {
continue
}
if unmanagedTag(c.Tag) {
continue
}
var goneEmails []string
if err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", c.Id).
@@ -886,7 +891,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if uErr != nil {
return false, uErr
}
if !stillUsed {
// Usage, quota and expiry live on this row, so a client the orphan
// sweep will mark keeps it until the reaper confirms the removal.
if !stillUsed && !clientRecordExists(tx, existing.Email) {
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
@@ -901,6 +908,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
emails map[string]struct{}
}
var perInboundOld []oldSet
syncFailedInbounds := map[int]struct{}{}
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
@@ -973,10 +981,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
syncFailedInbounds[c.Id] = struct{}{}
}
}
for _, old := range perInboundOld {
// The sweep's premise is that links were just rebuilt from the snapshot,
// which is exactly what a failed SyncInbound violates.
if _, failed := syncFailedInbounds[old.inboundID]; failed {
continue
}
var stillAttached []string
if err := tx.Table("clients").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
@@ -1002,19 +1016,20 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if attachmentCount > 0 {
continue
}
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
}
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
}
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
// "Ended the merge unattached" is true for a real remote deletion and
// equally true for a bad merge, so record a strike instead of deleting.
if err := markSyncOrphan(tx, email, now); err != nil {
logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err)
continue
}
structuralChange = true
}
}
if err := clearSyncOrphanMarks(tx); err != nil {
logger.Warning("setRemoteTraffic: clear orphan marks failed:", err)
}
if err := liftActivatedClientRecordExpiries(tx); err != nil {
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
}
+24 -3
View File
@@ -652,9 +652,9 @@ func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag str
Updates(map[string]any{"inbound_tags": string(buf)}).Error
}
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
return
func nodeSelectedTagSet(n *model.Node) map[string]struct{} {
if n == nil || n.InboundSyncMode != "selected" {
return nil
}
prefix := nodeTagPrefix(&n.Id)
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
@@ -668,6 +668,27 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
}
}
}
return allowed
}
// A deselected tag is still served by the node — FilterNodeSnapshot just stops
// reporting it — so its absence must never be read as "the node deleted it".
func unmanagedTagPredicate(n *model.Node) func(string) bool {
managed := nodeSelectedTagSet(n)
if managed == nil {
return func(string) bool { return false }
}
return func(tag string) bool {
_, ok := managed[tag]
return !ok
}
}
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
return
}
allowed := nodeSelectedTagSet(n)
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
for _, inbound := range snap.Inbounds {
if inbound == nil {
@@ -0,0 +1,78 @@
package service
import (
"encoding/json"
"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 payloadClientEmails(t *testing.T, settings string) []string {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("settings not valid json: %v", err)
}
raw, _ := parsed["clients"].([]any)
out := make([]string, 0, len(raw))
for _, c := range raw {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if email, _ := cm["email"].(string); email != "" {
out = append(out, email)
}
}
return out
}
// Stripping disabled clients from a node push made the node delete the row and
// the master mirror that back: every depleted client destroyed, not disabled.
func TestNodePushKeepsDisabledClientsLocalPushStripsThem(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
clients := []model.Client{
{Email: "alive@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
{Email: "quota@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
}
ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
for _, c := range clients {
row := &xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed traffic %s: %v", c.Email, err)
}
}
if err := db.Model(xray.ClientTraffic{}).
Where("email = ?", "quota@x").
Update("enable", false).Error; err != nil {
t.Fatalf("deplete quota@x: %v", err)
}
t.Run("node payload keeps the depletion-disabled client", func(t *testing.T) {
built, err := svc.buildInboundForNodePush(db, ib)
if err != nil {
t.Fatalf("buildInboundForNodePush: %v", err)
}
got := payloadClientEmails(t, built.Settings)
if len(got) != 2 {
t.Fatalf("node push dropped a client: got %v, want both alive@x and quota@x", got)
}
})
t.Run("local xray payload still strips it", func(t *testing.T) {
built, err := svc.buildInboundForLocalRuntime(db, ib)
if err != nil {
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
got := payloadClientEmails(t, built.Settings)
if len(got) != 1 || got[0] != "alive@x" {
t.Fatalf("local runtime payload = %v, want only alive@x", got)
}
})
}
@@ -0,0 +1,162 @@
package service
import (
"fmt"
"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/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func countClientRows(t *testing.T, db *gorm.DB, email string) (records, traffics int64) {
t.Helper()
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&records).Error; err != nil {
t.Fatalf("count clients %q: %v", email, err)
}
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&traffics).Error; err != nil {
t.Fatalf("count client_traffics %q: %v", email, err)
}
return records, traffics
}
func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) {
t.Helper()
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
}
func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot {
t.Helper()
return &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
}
}
func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot {
t.Helper()
return snapshotWithClients(t, tag, `{"clients":[]}`)
}
func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot {
t.Helper()
return &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}},
{Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}},
},
}
}
// The job samples config_dirty before the snapshot round-trip; a client added
// in that window is deleted again unless the merge re-reads the flag itself.
func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "carol"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true})
if rec, _ := countClientRows(t, db, email); rec != 1 {
t.Fatalf("setup: client not attached, got %d rows", rec)
}
if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil {
t.Fatalf("mark node dirty: %v", err)
}
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
rec, traf := countClientRows(t, db, email)
if rec != 1 || traf != 1 {
t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
}
// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as
// "the node deleted it" wiped the master's copy of an inbound still running.
func TestDeselectedTagIsNotSwept(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{
Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true,
InboundSyncMode: "selected", InboundTags: []string{"keep"},
})
createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x")
createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x")
keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}`
dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}`
snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x")
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
t.Fatalf("seed sync: %v", err)
}
if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 {
t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf)
}
keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true})
if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false); err != nil {
t.Fatalf("post-deselect sync: %v", err)
}
rec, traf := countClientRows(t, db, "dropped@x")
if rec != 1 || traf != 1 {
t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
var inbounds int64
if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil {
t.Fatalf("count inbounds: %v", err)
}
if inbounds != 1 {
t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds)
}
}
func TestSyncInboundStoresTrimmedEmail(t *testing.T) {
db := initTrafficTestDB(t)
svc := &ClientService{}
ib := &model.Inbound{UserId: 1, Tag: "trim-in", Enable: true, Port: 41501, Protocol: model.VLESS}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
padded := "bob "
clients := []model.Client{{Email: padded, Enable: true}}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("first SyncInbound: %v", err)
}
var stored []string
if err := db.Model(&model.ClientRecord{}).Pluck("email", &stored).Error; err != nil {
t.Fatalf("read clients: %v", err)
}
if len(stored) != 1 || stored[0] != "bob" {
t.Fatalf("stored email = %q, want the trimmed %q — the lookup key must match what is written", stored, "bob")
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("second SyncInbound must not hit a unique-constraint on the untrimmed row: %v", err)
}
var links int64
if err := db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&links).Error; err != nil {
t.Fatalf("count links: %v", err)
}
if links != 1 {
t.Fatalf("links after re-sync = %d, want 1", links)
}
}
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "تبرع",
"hosts": "المضيفات",
"docs": "التوثيق",
"openMenu": "فتح القائمة"
"openMenu": "فتح القائمة",
"pinSidebar": "تثبيت الشريط الجانبي",
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"apiDocs": "API Docs",
"donate": "Donate",
"docs": "Documentation",
"openMenu": "Open menu"
"openMenu": "Open menu",
"pinSidebar": "Pin sidebar",
"unpinSidebar": "Unpin sidebar"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Donar",
"hosts": "Hosts",
"docs": "Documentación",
"openMenu": "Abrir menú"
"openMenu": "Abrir menú",
"pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "حمایت مالی",
"hosts": "میزبان‌ها",
"docs": "مستندات",
"openMenu": "باز کردن منو"
"openMenu": "باز کردن منو",
"pinSidebar": "ثابت کردن نوار کناری",
"unpinSidebar": "برداشتن تثبیت نوار کناری"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Donasi",
"hosts": "Host",
"docs": "Dokumentasi",
"openMenu": "Buka menu"
"openMenu": "Buka menu",
"pinSidebar": "Sematkan bilah sisi",
"unpinSidebar": "Lepas sematan bilah sisi"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "寄付",
"hosts": "ホスト",
"docs": "ドキュメント",
"openMenu": "メニューを開く"
"openMenu": "メニューを開く",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Doar",
"hosts": "Hosts",
"docs": "Documentação",
"openMenu": "Abrir menu"
"openMenu": "Abrir menu",
"pinSidebar": "Fixar barra lateral",
"unpinSidebar": "Desafixar barra lateral"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Поддержать",
"hosts": "Хосты",
"docs": "Документация",
"openMenu": "Открыть меню"
"openMenu": "Открыть меню",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Bağış Yap",
"hosts": "Host'lar",
"docs": "Belgeler",
"openMenu": "Menüyü aç"
"openMenu": "Menüyü aç",
"pinSidebar": "Kenar çubuğunu sabitle",
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Підтримати",
"hosts": "Хости",
"docs": "Документація",
"openMenu": "Відкрити меню"
"openMenu": "Відкрити меню",
"pinSidebar": "Закріпити бічну панель",
"unpinSidebar": "Відкріпити бічну панель"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Quyên góp",
"hosts": "Hosts",
"docs": "Tài liệu",
"openMenu": "Mở menu"
"openMenu": "Mở menu",
"pinSidebar": "Ghim thanh bên",
"unpinSidebar": "Bỏ ghim thanh bên"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "捐赠",
"hosts": "主机",
"docs": "文档",
"openMenu": "打开菜单"
"openMenu": "打开菜单",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏"
},
"pages": {
"login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "捐贈",
"hosts": "Hosts",
"docs": "文件",
"openMenu": "開啟選單"
"openMenu": "開啟選單",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄"
},
"pages": {
"login": {
+3
View File
@@ -291,6 +291,7 @@ const (
cadenceNodeHeartbeat = "@every 5s"
cadenceNodeTraffic = "@every 5s"
cadenceOutboundSub = "@every 5m"
cadenceReapOrphans = "@every 5m"
cadenceXrayLogPrune = "@every 10m"
cadenceCheckHash = "@every 2m"
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
@@ -336,6 +337,8 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
// Outbound subscription auto-refresh (respects per-sub updateInterval)
_, _ = s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob())
_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
// check client ips from log file every day
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
+1 -2
View File
@@ -85,7 +85,7 @@ func getLogPath(key string) (string, error) {
return logPath, nil
}
}
return "", err
return "", nil
}
// GetAccessLogPath reads the Xray config and returns the access log file path.
@@ -93,7 +93,6 @@ func GetAccessLogPath() (string, error) {
return getLogPath("access")
}
// GetErrorLogPath reads the Xray config and returns the error log file path.
// GetErrorLogPath reads the Xray config and returns the error log file path.
func GetErrorLogPath() (string, error) {
return getLogPath("error")
+4 -10
View File
@@ -71,24 +71,20 @@ func runWebServer() {
log.Fatalf("Error initializing database: %v", err)
}
var server *web.Server
server = web.NewServer()
server := web.NewServer()
global.SetWebServer(server)
err = server.Start()
if err != nil {
log.Fatalf("Error starting web server: %v", err)
return
}
var subServer *sub.Server
sub.SetDistFS(web.EmbeddedDist())
service.RegisterSubLinkProvider(sub.NewLinkProvider())
subServer = sub.NewServer()
subServer := sub.NewServer()
global.SetSubServer(subServer)
err = subServer.Start()
if err != nil {
log.Fatalf("Error starting sub server: %v", err)
return
}
sigCh := make(chan os.Signal, 8)
@@ -142,7 +138,6 @@ func runWebServer() {
err = server.StartPanelOnly()
if err != nil {
log.Fatalf("Error restarting web server: %v", err)
return
}
log.Println("Web server restarted successfully.")
@@ -152,7 +147,6 @@ func runWebServer() {
err = subServer.Start()
if err != nil {
log.Fatalf("Error restarting sub server: %v", err)
return
}
log.Println("Sub server restarted successfully.")
case sys.SIGUSR1:
@@ -360,7 +354,7 @@ func updateSetting(port int, username string, password string, webBasePath strin
if err != nil {
fmt.Println("Failed to set listen IP:", err)
} else {
fmt.Printf("listen %v set successfully", listenIP)
fmt.Printf("listen %v set successfully\n", listenIP)
}
}
@@ -579,7 +573,7 @@ func main() {
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" run run web panel")
fmt.Println(" migrate migrate form other/old x-ui")
fmt.Println(" migrate migrate from other/old x-ui")
fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
fmt.Println(" setting set settings")
}