* 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>
* 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.
* 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>
* 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>
* 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>
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>
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>
* 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.
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.
* 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>
* 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>
* 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
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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.
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.
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.
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
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.
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.
* 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>
* 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>
* 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>
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.
* feat(ui): tag settings that sit at their shipped default value
A field showing 2096 reads identically whether the install never set
it or the operator saved 2096 — newcomers cannot tell which knobs
they have touched, and after the cleared-port fix (#6121) a port can
never visually return to an unset state. Add a small grey tag next to
numeric settings whose current value equals the shipped default.
The tag deliberately compares values, not provenance: a stored 2096
and a fallback 2096 behave identically, so they read identically, and
the tag reacts live as the user types.
The backing endpoint filters defaultValueMap through the AllSetting
field set, so per-install material (secret, panelGuid, node mTLS
keys) and redacted credential fields never leave the server; a test
pins that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): keep the default tag out of the accessible name, pin the defaults contract
From review, in order of severity:
The badge was rendered inside the element whose id feeds the
control's aria-labelledby, so a visible tag changed every field's
accessible name ('Panel Port Default'). The title text now carries
the id on its own span and the badge sits beside it.
The same default values live in three places: the Go defaultValueMap,
the frontend AllSetting class, and the tag's verdict. A new contract
test parses the Go map's string literals and asserts every shared key
matches the AllSetting class default through the tag's own
comparison — and on first run it caught two real drifts
(tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but
'login.attempt,cpu.high' on the server), now aligned.
matchesFactoryDefault no longer coerces blank or unparsable defaults
(Number('') is 0; a junk string is not false). The Go tests are
table-driven t.Run subtests and gained the structural invariant:
every returned key is an AllSetting json tag outside the credential
deny-list. The service doc comment now describes the projection
mechanism instead of overclaiming; the i18n key is re-indented and
placed at the head of pages.settings in all 13 locales; the fetch
falls back to {} when validation fails; and smtpPort gets the tag so
plain numeric settings-list fields are covered uniformly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(web): pin the endpoints.ts registry to the actual Gin routes
endpoints.ts is a hand-maintained registry and nothing checked it
against the router: an omitted API route silently vanishes from the
generated OpenAPI docs, and an entry for a removed route documents an
endpoint that 404s. Two new tests construct the real router against a
throwaway DB and diff the /panel/api surface both ways.
The check found one gap on arrival: GET /panel/api/openapi.json — the
endpoint that serves the docs — was itself undocumented. Registered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api)+test: authenticate openapi.json, fold the two route-contract tests into one
Three things from the review, in severity order.
The bot found that GET /panel/api/openapi.json was registered on the
base-path group one line before the /panel/api group installs
checkAPIAuth, so Gin's snapshot of the parent chain meant the whole
admin API surface plus build version was fetchable without a session
— while this very PR was about to document it as auth-required. Move
the registration inside the authed api group. Verified: unauthenticated
it now 404s exactly like server/status (was 200), and a logged-in
session still serves it 200, so the docs page is unaffected.
The existing api_docs_test.go already checked the forward direction by
regex-scanning controller source against a hand-maintained per-file
path switch — which is why it missed this web.go-registered route, and
whose fall-through default silently mis-paths any unlisted controller
file. The new router-based test is a strict superset, so fold in the
extra surface it guarded (/login, /logout, /csrf-token,
/getTwoFactorEnable, /ws) and delete the old test rather than run two.
Harden the endpoints.ts parser: pair each method with the next path
sequentially instead of a brace-crossing regex, and fail loudly when
the parsed count doesn't match the declared method fields. Construct
the server once across both subtests, cancel it, and restore the
previous global on cleanup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(i18n): delete 230 dead translation keys and guard against new ones
The 13 locale files carried 230 keys (11% of the set) that nothing in
the frontend or Go sources references — leftovers of renamed features
(the email notifier reuses tgbot.messages.* for subjects, the old
email.subject*/title* set was orphaned; likewise menu.*, the clients
bulk-copy strings, and the secAlert* family). Nothing detected this:
a missing key falls back to en-US and an unused key fails nothing.
A new test now fails the build when an en-US key has no reference in
frontend/src or internal Go sources (dynamic keys are covered by
harvesting concatenation and template-literal prefixes), and pins
that all 13 locales carry exactly the en-US key set, so parity drift
surfaces at test time instead of as a silent fallback.
Each locale shrinks by the same 230 keys; net -2,900 lines across
the translation set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): restore the 29 live remarkVars keys, match whole tokens, unmask 9 more
From review: the template-literal harvester required the prefix to end
on a dot, so pages.hosts.remarkVars.desc${token} harvested nothing
and all 29 desc* tooltip keys were wrongly deleted — and the guard
shared the flawed logic, so CI stayed green while the Hosts page
would have shown raw key names in 13 languages. Restored from the
parent commit; the harvester now requires at least one dot but not a
trailing one.
Also from review: references are matched as whole dotted tokens
instead of substrings (a dead key can no longer hide behind a longer
sibling — that unmasked 9 more genuinely dead keys, each verified by
hand before deletion), and the test excludes itself from the scan so
its own prose cannot whitelist a subtree.
Net: -210 keys per locale instead of the previous -230.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>