Commit Graph

307 Commits

Author SHA1 Message Date
Sanaei 6638ac4a1e i18n: translate importKeepHostSettings keys
Translate the `importKeepHostSettings` and `importKeepHostSettingsDesc` keys from English placeholders into 10 locale files (ar-EG, es-ES, fa-IR, id-ID, ja-JP, pt-BR, tr-TR, vi-VN, zh-CN, zh-TW).

Also removes `main_test.go` which contained a test for a `commandHelp` function.
2026-08-18 12:27:33 +02:00
n0ctal d6472740dc feat(limitip): let operators exempt trusted addresses from the IP limit (#6230)
* feat(limitip): let operators exempt trusted addresses from the IP limit

Behind a shared address — an office gateway, a campus NAT, a residential
carrier — every user looks like the same client. One of them trips the IP
limit and the address is disconnected and handed to fail2ban, taking the
others with it. Today the only way out is editing jail.d by hand, which an
update overwrites.

Add an allowlist setting of addresses and networks. A matching address is
neither banned nor counted towards the limit: counting it would still cut the
shared network the entry exists to protect.

Entries are validated on save rather than skipped at scan time — a typo would
otherwise leave the address unprotected until someone noticed the bans.

* fix(limitip): keep each doc comment on its function and one grammar for the list

Three review follow-ups. loadAllowlist landed between hasLimitIp's doc comment
and hasLimitIp itself, so godoc showed one function's rationale above another's
body; it now sits after that function with its own comment.

The parser advertised semicolons and whitespace as separators while the
settings validator accepts commas only, making those forms unreachable through
the panel and the API — a promise the software never keeps. Both sides now read
the same comma-separated grammar.

The dist stub was a build artifact and does not belong in the tree.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

* chore(i18n): translate the IP limit allowlist strings into the remaining locales

Ten locales carried the English source text verbatim; only ru-RU and uk-UA
were translated. The i18n dead-key test only checks that a key exists in every
file, so an untranslated value passes it silently.

Wording follows each locale's existing terms: the ipLimit noun already in the
file, and the comma-separated IP/CIDR phrasing from trustedProxyCidrsDesc.

* refactor(limitip): share one IP/CIDR list validator and read the allowlist only when enforcing

The allowlist check in CheckValid was a line-for-line copy of the trusted-proxy
loop directly above it. Both now call one helper, each passing its own message,
so the two lists cannot drift apart.

Run() read the allowlist on every 10s scan, including the majority of panels
where no client carries an IP limit and the value is discarded. It is now read
only once enforcement is known to apply.

CheckValid had no test for either list. The new one pins that a malformed entry
is rejected and that each list still names itself in the error, which is what
the shared helper could otherwise break.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-18 12:23:10 +02:00
n0ctal 6e80a468e3 feat(server): keep this machine's own settings when importing a database (#6227)
* feat(server): keep this machine's own settings when importing a database

Import replaces the database wholesale, so the uploaded file's listen
addresses, ports, base path, certificate paths and node identity land on the
destination. Moving a configuration to a new host therefore leaves the panel
answering on an address it does not own, presenting certificates it does not
have, and claiming the source machine's identity towards its nodes.

Capture the host-bound settings before the swap and write them back once the
imported database opens. Everything else — inbounds, clients, templates, the
rest of the settings — still comes from the file.

A checkbox controls it, defaulting to keeping this machine's values; clearing
it restores the old behaviour for anyone deliberately cloning a host.

* fix(server): drop imported host settings this machine never had, and cover Postgres

Two gaps in the previous commit. The snapshot only recorded rows that existed,
so a key with no row here — the default for every certificate path, both listen
addresses and all the node mTLS material — kept the imported value: exactly the
case the change is meant to fix. The snapshot now records which keys were
absent and deletes the imported row for them, letting the default apply again.

The PostgreSQL path took the flag and ignored it, so a dump restore still
adopted the source machine's settings. It now captures and restores the same
way the SQLite path does.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-18 12:03:52 +02:00
n0ctal e940f30bb8 feat(clients): cap how many times a client may auto-renew (#6238)
* feat(clients): cap how many times a client may auto-renew

Auto-renew today runs forever: a prepaid or fixed-term client keeps being
handed new periods until an operator remembers to switch it off. There is no
way to say "renew this three times, then let it lapse".

Add a per-client maximum. Zero keeps today's behaviour, so nothing changes for
anyone who does not set one. When the count is reached the client is simply
left to expire, like any client without auto-renew.

Catching up several missed periods spends one allowance per period. A client
that was away for three cycles must not receive three of them free of the cap,
and the catch-up stops at the last period the cap paid for rather than jumping
to the present.

* fix(clients): persist the auto-renew cap and stop the capped churn

resetMax lived only in the inbound settings JSON and client_traffics, so
every path that rebuilds a client from the clients table wrote it back as
zero. The edit dialog showed 0 for a capped client, and saving an
unrelated comment change lifted the cap; an attach or a traffic reset did
the same with no operator action at all.

Adds reset_max to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge, the record update map and ClientSlim, so the cap
survives the round trip.

When the cap truncates a catch-up the client is still expired, but the
renewal side effects fired anyway: counters were zeroed for periods it
can never use, and it was enabled and pushed to xray only for
disableInvalidClients to undo both in the same transaction. Those are now
skipped when the new expiry has not reached the present.

Also makes any non-positive resetMax mean unlimited instead of silently
meaning "never renew again", rejects a negative one at the service layer,
surfaces renewals used against allowed in the client info modal so the
operator can see what to raise, adds the field to the bulk-add modal,
translates the labels in all 13 locales, and drops the stray
internal/web/dist/.gitkeep build stub.

* fix(clients): let the renewal cap be changed after creation

ClientService.Update writes the record columns directly only for a client
with no inbounds. The normal path goes through SyncInbound and
applyClientRecordMerge, which this change had not extended, so raising a
cap from 3 to 6 — the natural action when a customer buys another block
of periods — updated the inbound settings JSON while clients.reset_max
kept the old value and the renewal query kept enforcing it.

The existing test did not catch it: it asserted the cap survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheRenewalCap raises the cap and then
lifts it entirely; removing the record write turns it red.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-08-18 11:53:11 +02:00
n0ctal 6a674c7f0c fix(node): keep disabled inbounds the node snapshot cannot report (#6221)
* fix(node): keep disabled inbounds the node snapshot cannot report

A node builds its traffic snapshot from the inbounds Xray is actually running,
so an inbound with enable=false is never in it. The central sweep reads that
absence as "the node no longer has this inbound" and deletes the row, its
clients' traffic history and its port reservation — on a perfectly healthy
node, with no way to tell it apart from a real deletion.

Disabling an inbound in the panel and waiting one sync interval is enough to
lose it. Skip disabled inbounds in the sweep: their absence carries no
information, and an explicit delete still removes them.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.
2026-08-18 11:50:09 +02:00
n0ctal 81cfd8570e fix(inbounds): close the port check-and-claim race on the serial writer (#6225)
* fix(inbounds): close the port check-and-claim race on the serial writer

AddInbound reads the port conflict outside its transaction and then commits in
a bare db.Transaction, so two overlapping creates both pass the read and both
insert. UpdateInbound already runs on the single traffic writer, and so does
the node snapshot path; AddInbound is the one inbound writer left out.

Move it onto runSerializedTx and evaluate the conflict inside the transaction,
in both AddInbound and UpdateInbound. The check and the claim then commit
together on one goroutine, which closes the window on SQLite (immediate write
lock) and PostgreSQL alike without new schema, locks or configuration.

The wildcard/specific pair is the case worth naming: those are two distinct
rows, so no unique index can reject them — only the semantic check can, and
only if nothing can interleave between it and the insert.

* fix(inbounds): restore the port check UpdateInbound lost

The previous commit deleted UpdateInbound's pre-flight conflict check and never
added the in-transaction one, so editing an inbound onto an occupied port was
accepted outright. No test covered that path, so CI stayed green.

Evaluate the conflict inside the transaction, as AddInbound already does, and
add the regression test that fails without it.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-08-18 11:48:40 +02:00
n0ctal 5c9268c431 feat(i18n): translate the log levels, access events and calendar labels (#6226)
* feat(i18n): translate the log levels, access events and calendar labels

The log-level selector, the access-log event tags, the Sub Formats sidebar
entry and the calendar choices were hardcoded English, so a fully translated
locale still showed them in English on core screens.

Add eleven keys across the 13 locales and reference them. Russian and
Ukrainian are translated; the remaining locales carry the English string, the
same convention the existing files already use for untranslated entries.

Two module-level constants had to move: the calendar list and the access-event
map were built outside the component, where t is not in scope. The event map
now stores keys and resolves them at render.

* fix(i18n): keep the log export language-independent and fit the translations

Three follow-ups from review. The downloaded x-ui.log had started carrying the
translated event text, so its contents depended on the panel language and the
Russian value for PROXY contains a space in a field format whose other values
are single tokens. The export keeps DIRECT/BLOCKED/PROXY; only the on-screen
tag is translated.

The log-level select had a fixed 95px width sized for "Warning", which clips
"Предупреждение"; it now grows with its content.

The three access filters stayed English while the tags they filter became
translated, so they use the same keys.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-08-18 11:43:59 +02:00
n0ctal 2b1fe1fd02 ci: actually run the PostgreSQL schema and migration tests (#6224)
* ci: actually run the PostgreSQL schema and migration tests

TestHostAutoMigrateCreatesColumns_Postgres and TestMigrate_Postgres skip
unless XUI_DB_TYPE and XUI_DB_DSN are set. CI sets them only for the
durable-first step, so both tests have never run: a green pipeline says
nothing about the PostgreSQL schema or the migration path.

The job already has a PostgreSQL service. Point those two tests at it and
fail if either skips, the same guard the durable-first step uses.

* ci: make the PostgreSQL guard fail on a renamed test, and self-test the workflow

The guard asserted the absence of `--- SKIP`, which only catches a test that
ran and skipped. A renamed or deleted test makes `-run` match nothing, so
`go test` prints "no tests to run" and exits 0 — the step stays green while
testing nothing, which is the exact failure this PR set out to close.

Both steps now count `--- PASS` lines and require the expected number: at
least one for durable-first, exactly two for the schema tests.

Also adds `.github/workflows/ci.yml` to both `paths` filters so a change to
the workflow runs the workflow — without it this PR's own CI never fired and
the new step would first execute on main after merge — and hoists the
duplicated DSN to job-level `env`.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-08-18 11:38:14 +02:00
n0ctal 4b0e9f9b60 fix(nodes): log the inbound the node snapshot removes centrally (#6219)
The orphan sweep deletes a central inbound and the traffic history of every
client on it, but wrote nothing. An inbound that vanishes minutes after being
created is then indistinguishable from one that never arrived, and the only
way to tell them apart is reading the source.

Name the node, tag, id and port so the removal is visible in the panel log.

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-16 14:02:50 +02:00
Rouzbeh† 5d6d98d1f9 fix(warp): preserve WARP Plus license key when changing IP (#6218)
ChangeWarpIP rotated the WireGuard keypair by registering a brand-new
Cloudflare device via RegWarp, which overwrites the stored warp data with
the fresh registration's empty license_key. The old key was then re-applied
only best-effort: any SetWarpLicense failure was swallowed with a warning
log, permanently deleting the saved WARP Plus key, and even on success the
response returned to the UI carried the pre-reapply snapshot (empty key).

Fix: write the old license key back into the stored warp data immediately
after RegWarp (before the remote upgrade attempt), so storage never loses
it; keep the remote re-apply as best-effort but surface its failure as a
warning field in the response; and return the final stored data so the
modal shows the preserved key. The auto-update IP job shares this path and
is fixed too. warpAPIBase is now a var so integration tests can point at a
mock Cloudflare API.

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
2026-08-16 14:00:16 +02:00
Farhan Zare 930a0ed59d feat(inbound): DisableFlow — opt an inbound out of auto XTLS Vision (#5689) (#5698)
* feat(inbound): add DisableFlow to opt an inbound out of auto XTLS Vision

Adds an inbound-level DisableFlow flag so operators can suppress automatic
xtls-rprx-vision injection on a specific inbound even when its transport is
flow-capable — e.g. a tunneled/CDN-fronted XHTTP+vlessenc inbound where Vision
is not wanted, while keeping it on the same client's Reality inbounds.

When set, the inbound reports tlsFlowCapable=false, the write path clamps each
attached client's flow to empty (so flow_override stores ""), and share
links/subscriptions never carry the flow for it. The flag is panel-only
metadata and is never sent to xray.

Closes part of #5689.

* feat(inbound): DisableFlow toggle in the inbound form (frontend)

Wire the DisableFlow field through the form schema + adapters and add a
VLESS-gated switch in the inbound form, plus en-US strings. tsc --noEmit and
eslint pass.

* fix(inbound): honor DisableFlow in all emitters + on toggle; regen OpenAPI

Addresses review on #5690:
- Clash (clash_service.go) and JSON (json_service.go) subscription emitters now
  also skip the flow for a DisableFlow inbound — previously only the raw
  share-link path was gated, so those two still advertised it (blocking 1).
- UpdateInbound now strips any flow already stored on a DisableFlow inbound's
  clients (settings.clients[].flow + client_inbounds.flow_override) so xray and
  the subscription agree; otherwise toggling DisableFlow on an existing Vision
  client left xray expecting a flow the client no longer sends.
- Regenerated the OpenAPI + zod/types/examples artifacts for the new field and
  added an example tag (blocking 2; make gen-check is clean).
- Added Clash + JSON DisableFlow suppression tests alongside the raw-link one.

* fix(inbound): make DisableFlow durable, clamp on create, guard live config

Addresses the review + completeness audit on #5690:
- UpdateInbound now persists inbound.DisableFlow onto the saved row. It was
  only read to branch strip-vs-restore, so toggling the flag on an existing
  inbound never stuck and MigrationRestoreVisionFlow re-injected the flow — the
  exact #5689 path (editing a multi-inbound client's inbound) self-reverted.
- DBInbound (frontend) declares + initializes disableFlow so ObjectUtil
  .cloneProps carries the API value through; the edit Switch previously always
  read false and re-saving silently reverted the opt-out.
- AddInbound strips client flow (settings + parsed clients) when DisableFlow is
  set, so a created-disabled inbound never persists a flow xray would expect.
- GetXrayConfig forces flow="" for DisableFlow inbounds (VLESS + Trojan) as
  defense-in-depth, keeping the live config and the subscription in agreement.
- genTrojanLink share link honors DisableFlow too.
- Drop the dead explicit flow_override clear in UpdateInbound (SyncInbound
  rebuilds it from the stripped settings).
- Clear disableFlow in the inbound form when switching to a non-VLESS protocol.
- Add disableFlow/disableFlowHelp to the remaining 12 locales.

Tests: stripClientFlows unit cases; DB-backed AddInbound clamp; UpdateInbound
persist+strip+resist-restore regression (fails without the persist fix);
frontend DBInbound + adapter round-trip (fails without the model field).

* style(inbound): drop // line comments per repo CLAUDE.md

The DisableFlow work followed the surrounding code's commenting style; the repo
CLAUDE.md forbids // line comments in committed Go/TS. Remove the comments I
added (Go + frontend + tests) and regenerate OpenAPI/schemas, which drops the
generated field descriptions sourced from the Go doc comments. No behavior
change; full go test (service+sub, CGO) + frontend typecheck/vitest green;
golangci-lint clean on the changed files.

* fix(runtime): propagate disableFlow to nodes

Preserve the inbound DisableFlow flag when syncing inbounds across nodes and when recreating central records from remote traffic snapshots. This keeps multi-node deployments from reintroducing VLESS Vision flow in node configs and share links, and updates the related tests to cover the wired field and VLESS JSON generation.
2026-08-15 23:09:16 +02:00
Sanaei f22df49a71 fix(sub): restore the subscription info page for browser visits
Revert 43bc9153 and its follow-up 338822ab. The copy-only notice replaced the
themed sub page for every browser request, so mobile users got a bare "This is
a subscription link" screen instead of their traffic, expiry and links — and it
left serveSubPage plus the custom-theme renderer as dead code.
2026-08-15 22:11:37 +02:00
n0ctal b4e4478699 feat(inbounds): add a narrow endpoint for subscription sort order (#6179)
* feat(inbounds): add a narrow endpoint for subscription sort order

Changing an inbound's position in subscription output currently goes through
/update/:id, which takes a whole inbound: the caller has to send settings and
the entire client list back, and whatever it read before the edit is what gets
written. Two people reordering and editing clients in the same inbound race on
one blob, and the reorder wins by overwriting.

Mirror the existing /setEnable/:id shape. The handler takes only the index and
the service reads the stored inbound, so nothing in the request can reach the
settings JSON. Node-owned inbounds are marked dirty in the same transaction and
pushed through the existing runtime update.

* fix(nodes): scope sub sort index updates

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 21:48:24 +02:00
n0ctal bab39393f1 fix(nodes): validate every certificate in the node mTLS trust bundle (#6188)
* fix(nodes): validate every certificate in the node mTLS trust bundle

AppendCertsFromPEM reports success once a single certificate parses, so a
trust bundle whose later entries are damaged or truncated was accepted with
those entries silently absent from the pool. Parse and validate every PEM
block instead, and reject the bundle if any of them is malformed.

* fix(mtls): reject malformed certificate bundle layout

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 21:31:31 +02:00
n0ctal 43bc915397 fix(sub): serve a copy-only page when a subscription URL is opened in a browser (#6183)
* fix(sub): show copy-only page for browser subscription visits

Browser navigation to /sub previously rendered the normal subscription page, which exposed subscription material in page data or raw base64 depending on request headers. Keep VPN clients on the raw subscription body, but classify browser document requests and return a neutral static copy-only HTML page with no embedded share links or page data.

This preserves the C1 LimitIP parser fix in the same master candidate while avoiding a DE rollback of the browser subscription UX.

* fix(sub): keep the themed page for an explicit html request

Only implicit browser navigation is downgraded to the copy-only page. An
operator who appends html=1 or view=html already holds the URL, so the
themed subscription page keeps rendering for them and serveSubPage stays
in use.

* fix(sub): keep browser pages copy-only
2026-08-15 18:18:03 +02:00
isultanov99 be70535b94 feat(inbounds): improve multi-node online attribution (#6164) 2026-08-15 17:40:35 +02:00
isultanov99 2d669fa4b7 feat(sub): add template variables to subscription metadata (#6163) 2026-08-15 17:38:29 +02:00
Lex Rivera 8c8556ab32 feat(frontend): multi-node cloning initial implementation (#6216)
* feat(frontend): multinode cloning initial implementation

* fix(frontend): harden live node detection in multinode cloning

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(frontend): add aria label to clone inbound modal

* fix(frontend): shallow copy inbound settings during cloning

* fix(frontend): avoid potential port conflict during testing

* fix(frontend): selection buttons and websocket selection reset fix

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-15 17:36:25 +02:00
Grigoriy d7698ec7aa feat(xray): browse geosite/geoip categories from routing rules (#6165)
* feat(xray): browse geosite/geoip categories from routing rules

Routing rules made you type category names from memory: nothing showed which
categories a database actually contains, what is inside one, or whether a
name resolves at all — a typo only surfaced when Xray refused the config.

The panel now reads Xray's .dat databases itself and exposes them over four
endpoints: databases in the asset folder, a database's categories, one page
of a category's rules, and validation of the tokens already in a rule. The
reader walks the protobuf wire format directly rather than decoding into Go
structs, because a 10 MB geosite.dat holds well over a million domains and
materialising them costs ~284 MB where streaming costs ~19 MB. Only the
category index is cached, entry pages are scanned on demand, and scans are
serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB.
A database's type is decided by its contents, not its file name, since
custom .dat files are named freely.

In the rule form, the source-IP, IP and domain fields gain a database button
opening the browser: search over categories, a preview of what a category
holds, and a multi-select that merges into the field. Plain domains, CIDRs
and categories the panel does not know are left untouched; categories already
present come back ticked, and unticking one removes it from the rule.

* fix(xray): read geo databases through os.Root and match codes verbatim

CodeQL flagged the database read as a path built from a user-supplied value,
and it was right about the shape of it. The file name arrives in a request;
resolve() rejects traversal and stats the file through an os.Root, but the
read itself went through a joined path with os.ReadFile. That left the
symlink defence incomplete: the stat could pass while the read followed a
link planted — or swapped in — afterwards.

Reads now go through the same root, so a request-supplied name never becomes
a path this code resolves on its own, and the size limit is applied to the
opened file rather than to a separate stat of it.

Lookup no longer trims the category code either. It backs the routing-token
validator, and the core matches codes verbatim: "geosite: cn" will not start
Xray, so repairing that space here hid exactly the typo the validator exists
to report.

* fix(xray): address review findings on the geo category browser

Asset folder. The browser read config.GetBinFolderPath() unconditionally,
but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the
bin folder (ensureXrayAssetLocation). On an install pointing at a shared
asset directory the panel listed an empty folder and reported perfectly
valid geosite:/geoip: tokens as missing — the validator warning about a
correct config. The directory is now resolved with the core's precedence.

Paging. Serving one page read and rescanned the whole database, so walking
category-ads-all re-read it per page. The index now records each category's
byte range and a page reads only that record through the os.Root handle,
with the current category's records held for the duration of a paging
session. Profiling that also showed the real cost was not the read but the
slice of payload pointers built per call — a category holds a hundred
thousand of them — so records are now walked with a callback instead.
Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB.

Cached failures. Any error from reading a file was latched under the file's
size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as
damaged until it changed on disk. Only deterministic failures are cached.

Wrong kind. A geoip: token typed into a domain field parsed as a plain
domain and was waved through, though the core cannot resolve it as one. It
is now reported, with its own reason and wording.

Frontend. The category filter fed the query key on every keystroke, so each
character triggered a request that re-scanned the database; it is debounced
now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus
these three fields on a validation error again. A failed validation shows
that it failed instead of rendering the same empty state as "no issues".

Also drops an unreachable branch in the token-count guard and corrects the
categories endpoint docs, where limit is unbounded by default.

---------

Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
2026-08-15 17:12:59 +02:00
Rouzbeh† 694ad6deae feat(sub): add per-client subscription HWID limits (#5802)
* feat(sub): add per-client subscription HWID limits

* fix(sub): address HWID review on shared subId and bulk create

* fix(sub): store HWID devices by sub_id and drop anchor client workaround

* fix(sub): restore UA auto-detect and HTML page routing in subs()

The cherry-pick of the HWID gate onto main's refactored SUBController
had dropped main's UA-based format auto-detection and sub-page handling
from subs(). Restore those branches, slotting enforceHwid after the
HTML page and before format detection so the gate only applies to
machine-readable subscription bodies.

Also adapt tests to main's options-struct constructor and to the
ClientService.Update signature extended with limitHwid.

* fix(frontend): drop axios from HttpUtil.delete

The bulk-delete rework's committed version still referenced axios,
which this file no longer imports, breaking typecheck in CI. Use the
httpRequest wrapper like the other verbs.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-15 16:50:20 +02:00
n0ctal 1793a9b8b4 feat(nodes): opt-in encryption at rest for the outbound node API token (#6186)
* node: encrypt outbound bearer token at rest

* fix(nodes): keep bearer tokens encrypted throughout

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 16:48:50 +02:00
Dan Liutko 0f14ce7551 fix(web): fallback to default secret when database setting is empty (#6189)
* fix(web): fallback to default secret when database setting is empty

* style(web): format setting_security_test.go with gofumpt
2026-08-15 16:40:22 +02:00
n0ctal 7ecd88b9e3 fix(nodes): apply a rotated master mTLS certificate without restarting the panel (#6194)
* fix(mtls): invalidate pooled clients after credential rotation

* fix(mtls): make connection reload read-only

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 16:03:42 +02:00
n0ctal 1230559e69 feat(api): scoped, optionally expiring API tokens (#6201)
* security(api): add scoped expiring API tokens

* security(api): make scoped token lifecycle enforceable

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 15:31:49 +02:00
n0ctal aecbad3ab1 test(tgbot): detect open-coded keypad transitions (#6214)
Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 15:27:02 +02:00
Maxim Myalin 2217213e9f feat(sub): expose last subscription fetch time (#6217)
* feat(sub): expose last subscription fetch time

Record successful GET subscription fetches per client and surface the timestamp in the client API and UI.

* fix(frontend): include last subscription fetch in client traffic

* Update sub_fetch_test.go

---------

Co-authored-by: Hermes Agent <hermes-agent@localhost>
2026-08-15 15:22:11 +02:00
n0ctal 34c248bb79 fix(cli): stop -getApiToken accumulating admin tokens (#6175)
* fix(cli): stop -getApiToken accumulating admin tokens

`x-ui setting -getApiToken` reads like a getter, but when tokens already exist
it minted a brand-new one named `cli-fallback-<unix>` on every invocation. The
plaintext is printed once and the row stays enabled forever, so an operator who
runs the command a few times while debugging silently leaves several
admin-equivalent credentials behind that nobody can tell apart or revoke
knowingly.

Keep the convenience the fallback was added for, but rotate a single
`cli-fallback` token instead: RecreateByName drops any existing row with that
name before issuing a new one, so at most one CLI-issued token exists at a time
and the previous plaintext stops working.

* fix(api-token): preserve token on failed replacement

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:12:43 +02:00
n0ctal c5dec64d36 fix(clients): push bulk client changes to nodes only after the commit lands (#6181)
* fix(clients): apply bulk mutations after durable commit

* test(clients): guard bulk pushes behind commit

* fix(clients): fully delete remote bulk clients

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:11:19 +02:00
n0ctal b56b087254 fix(migration): stop a half-applied startup migration from committing silently (#6182)
* fix(traffic): check maintenance commits and IP-limit errors

* fix(migrations): propagate transactional failures

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:10:39 +02:00
n0ctal 60453bf523 fix(nodes): persist the master mTLS credential atomically and stop silent reissue (#6195)
* fix: harden master mTLS credential persistence

* fix(mtls): validate and serialize credential persistence

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:03:54 +02:00
n0ctal bb29b6afec fix(node): adopt a matching deployed inbound instead of recreating it (#6197)
* fix(node): adopt compatible origin inbounds without mutation

* fix(nodes): preserve ambiguous and adopted aliases

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:01:35 +02:00
n0ctal 0496c23a26 fix(groups): report changed bulk moves without restarting xray (#6199) 2026-08-14 19:50:06 +02:00
n0ctal 1396005082 fix(traffic): apply maintenance side effects only after the commit lands (#6200)
* fix(traffic): apply maintenance after durable commit

* fix(traffic): apply all runtime maintenance after commit

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 19:42:09 +02:00
n0ctal b70c5abce8 fix(outbounds): propagate allocation query failures (#6208)
* fix(outbounds): propagate allocation query failures

* test(outbounds): cover update allocation failure

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 19:41:24 +02:00
n0ctal 20b3f84f77 fix(web): report unexpected HTTP serve failures (#6210)
* fix(web): report unexpected HTTP serve failures

* test(web): cover normal close and all HTTP servers

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 19:40:53 +02:00
n0ctal 238e4bb314 refactor(tgbot): share numeric keypad transitions (#6211)
Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-13 12:41:47 +02:00
n0ctal 4a5f6771b3 fix(nodes): report probe heartbeat persistence failures (#6207)
Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-13 12:34:37 +02:00
n0ctal 64f4f0746c fix(warp): surface update-clock persistence failures (#6209)
Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-13 12:31:05 +02:00
Chen, Ting-An 69a8237581 fix(i18n): localize Chinese Xray labels (#6202)
* fix(i18n): localize Traditional Chinese Xray labels

Several navigation, outbound, balancer, VPN, and DNS labels still displayed their English source text in the zh-TW interface. Translate the non-protocol labels while retaining established Xray terminology.

* fix(i18n): localize Simplified Chinese Xray labels

Mirror the reviewed Xray UI coverage in zh-CN so the same labels no longer fall back to English there.

Signed-off-by: 陳廷安 <73953029+nrps9909@users.noreply.github.com>

---------

Signed-off-by: 陳廷安 <73953029+nrps9909@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-12 16:56: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 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
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 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
Sanaei c56f6447a8 chore: refresh dependencies and modernize Go test idioms
Frontend deps: @hookform/resolvers 5.4.3 -> 5.5.7, Storybook 10.5.4 -> 10.5.5
across the four packages we declare, globals 17.7.0 -> 17.8.0, and jsdom
29.1.1 -> 30.0.1. The jsdom major replaces its CSS and selector stack --
@asamuzakjp/css-color 5 -> 6, @asamuzakjp/dom-selector 7 -> 8, undici 7 -> 8,
nwsapi and generational-cache folded into their parents, whatwg-url 17 nested
underneath. Nothing in the Vitest suites reaches those directly and the whole
frontend gate (typecheck, lint, tests, build, Storybook compile) is green.
Panel frontend version to 0.6.0.

Backend deps: mattn/go-sqlite3 1.14.48 -> 1.14.49 and valyala/fasthttp
1.72.0 -> 1.73.0, plus the golang.org/x/exp and genproto/googleapis/rpc
indirect bumps that came with them.

Go tests: modernize -fix output, covering range-over-int, sync.WaitGroup.Go
in place of manual Add/Done pairs, maps.Copy, and Go 1.26 new(expr) for
pointer-to-value in the forwarded-trust table. The storedAs helper is deleted
instead of being left behind a //go:fix inline directive -- keeping it that way
fails govet on the one call site the rewrite did not reach, and every caller now
takes new(...) directly. Behaviour is unchanged.

DnsTab: the hosts-sync effect tested dns while declaring dnsEnabled in its
dependency array. Both carry the same truth value, so this is exhaustive-deps
hygiene rather than a behaviour change.
2026-07-30 03:14:22 +02:00
Sanaei f52c3c4837 perf(clients): make the clients page scale to large panels
The clients page was slow on panels with many clients for two independent
reasons: the server rebuilt the whole picture on every request, and the
browser rebuilt the whole table on every poll.

Server side, ListPaged loaded every client row, every client_inbounds link
and every client_traffics row into Go memory, then filtered, sorted and
paginated in a loop -- on a request the page repeats every five seconds.
Every predicate now runs in SQL and only the requested page's ids are
hydrated, so the cost tracks the page size rather than the client count.
Measured on SQLite with a realistic status mix: the default view at 100k
clients goes from 1,072ms to 64ms. Behaviour is preserved deliberately in
the subtle places -- the cross-panel global-traffic overlay is folded into
the same used-bytes expression the predicates and sort use, LIKE wildcards
are escaped so a search for "a_b" stays literal, and the two different
tiebreak rules the in-memory comparator had are reproduced per sort key.

The summary's per-bucket email lists are capped at 200 with exact counters
beside them. They only back hover popovers, but shipping every match made
the response grow with the panel: at 100k clients it carried ~42k emails,
and the page revalidated all of them through a strict Zod parse every five
seconds. The popover now shows a "+N" chip for the remainder.

Browser side, the page fired three sequential list requests per load and
threw the first two away: the query went out before the persisted sort was
applied, and again before the configured page size was known -- 0 meaning
"one long page" is indistinguishable from "not loaded yet". The page size
is now derived rather than mirrored through an effect, and the previous
visit's value is remembered so the single request goes out at mount instead
of queueing behind /setting/defaultSettings.

Then the per-poll work. Reading isFetching made it a tracked property, so
the refetch interval notified twice per cycle and re-rendered the page even
when structural sharing left the data identical. Xray reports a traffic row
per client whether or not it moved bytes, so the speed map was mostly zeros
and was replaced wholesale every push; zero rows are now dropped and an
unchanged result returns the previous object, which lets React bail out
instead of re-rendering. The five Tooltip-wrapped buttons and the inbound
chips per row do not depend on traffic at all and are now memoised, keyed on
the email because a push replaces the row object of every client whose
counters moved. antd's hashed:false drops 3,311 :where(.css-<hash>) wrappers
and 29% of the generated stylesheet, and a pinned cssVar key stops each of
the eleven page-level ConfigProviders minting its own token scope.

Two callers that only need the mutations, GroupsPage and ClientBulkAddModal,
no longer start the list query -- the groups page had been polling the full
paged list every five seconds for data it never renders.
2026-07-30 02:49:32 +02:00
PathGao af5a8e5d40 fix(database): create SQLite backup snapshots online (#6137)
* fix(database): snapshot SQLite backups online

Use SQLite's online backup API for downloadable backups and SQLite migration exports instead of checkpointing then reading the live database file. The regression test validates a backup made while writes continue.

* style(database): group SQLite driver imports

* fix(database): bound online backup retries

Use a single backup step and a bounded connection-acquisition/retry context. Tighten temporary-file cleanup and regression assertions while removing the unused checkpoint helper.

* test(database): cover existing backup destinations

* fix(database): harden SQLite snapshot lifecycle

Sweep interrupted snapshot directories at SQLite startup, keep rollback-journal backups incremental, and make caller-owned cleanup explicit. Reuse one scheduled Telegram snapshot across administrators and make the direct SQLite driver dependency explicit.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 21:04:55 +02:00
PathGao ad288a7ecc fix(sub): honor trustedProxyCIDRs before forwarded URLs (#6135)
* fix(sub): honor trustedProxyCIDRs before forwarded URLs

* fix(sub): avoid unused trust-setting lookups

Skip the trustedProxyCIDRs lookup when no forwarded header can affect a subscription URL. Keep the shipped proxy default in one exported setting constant and document the subscription-link behavior for custom proxy boundaries.

* fix(frontend): meet config text contrast requirements

Keep compact configuration text readable in the light theme and satisfy the Storybook accessibility check.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 21:01:59 +02:00
PathGao ad5f2a28cb fix(xray): synchronize lifecycle state (#6138)
* fix(xray): synchronize lifecycle snapshots

Protect process replacement and result caching with a lifecycle state object, so read paths keep one process snapshot while restarts swap state safely. Bound version probing to prevent a stalled binary from holding the restart lock.

* test(xray): cover concurrent lifecycle reads

Exercise status, result, and traffic reads while the managed process is replaced, so the race detector guards the lifecycle snapshot boundary.

* fix(xray): guard process config snapshots

Synchronize hot-applied config snapshots, keep Telegram reads on one lifecycle snapshot, and strengthen lifecycle timeout and concurrency regression coverage.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 21:00:29 +02:00
Sanaei c3fa73d5a0 feat(ui): redesign the overview page as a trend-first command deck
Replace the ten-small-cards overview with an action bar, four vitals
tiles carrying 72-sample sparklines seeded from /server/history, a
two-series throughput chart, a TCP/UDP connections chart, and a
grouped system strip (uptime xray|os, panel ram|threads, ip
addresses). StatusCard and XrayStatusCard are deleted; every modal
stays reachable from the action bar, the Xray error message moves
into a tooltip on the state pill, and the panel version text keeps
opening the update modal (the dev-channel switch lives there) even
when no update is available. Live values sit beside the
upload/download and tcp/udp legends, a health sentence appears only
when a vital crosses the shared warn/crit thresholds now exported
from models/status, and load average is left to System History.

The sidebar becomes an auto-collapsed 72px icon rail that expands as
an overlay on hover: rail width, brand-row height and menu paddings
are pinned so nothing shifts during the transition, the collapsed-menu
tooltips are disabled, hover state survives the per-page sidebar
remounts (with a matches(':hover') resync), and the manual collapse
trigger is gone.

Sparkline gains rgb()/rgba() support in its fill gradient, a
showLegend prop so pages stop reaching into its internals, and loses
a dependency-less repaint effect that doubled canvas paints. Chart
tooltips show clock time via the new TimeFormatter.formatClock;
accents come from theme tokens instead of status.cpu.color. Verified
by screenshot at 390/800/1150/1280/1400/1600px in light and dark,
en and fa-IR, plus programmatic geometry checks on the sidebar.
Locale files gain 8 keys and lose 9 dead ones across all 13
languages.
2026-07-29 20:17:37 +02:00