* 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>
Constrain the copy-only subscription page to the dynamic viewport, wrap long localized text, and infer text direction so mobile browsers cannot render a horizontally shifted desktop-width page. Add regression coverage for the responsive layout contract.
* 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(sub): warn when salamander settings cannot reach the client
A hysteria2 share link carries obfuscation as obfs=salamander plus
obfs-password, and nothing else. Xray's finalmask accepts more than that —
packetSize among them — and those extra settings change what the server expects
on the wire. The emitted URI then looks complete but describes a server the
client cannot reach: every standard client applies plain salamander, the server
drops the packets, and the failure is silent on both ends.
Log the unexpressible keys when building such a link, naming the inbound, so the
cause is visible instead of appearing as a client-side problem.
* fix(sub): deduplicate salamander warnings
* 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(sub): use a fullwidth percent in USAGE_PERCENTAGE
A remark is placed in the share link fragment, so an ASCII percent is
percent-encoded to %25. Happ treats such a fragment as malformed, discards the
whole remark and falls back to showing the server hostname, which defeats the
point of a remark template and leaks the host into the client's server list.
Emit U+FF05 FULLWIDTH PERCENT SIGN instead. It renders the same to a reader,
never produces %25, and round-trips through url.Parse unchanged.
* test(sub): exercise production fragment encoding
---------
Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
* 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(database): keep IP limits when the fail2ban probe is inconclusive
ResetIpLimitNoFail2ban clears limitIp on every client — inbound settings JSON
and the clients table — whenever fail2banCanEnforce() returns false, then
records itself in the seeder history so it never re-evaluates. The probe was a
single `fail2ban-client -h` run, so it answered false both when fail2ban is
genuinely absent and when the command merely failed that once: a panel that
starts before fail2ban is up, or in a container where it is installed a moment
later, permanently loses every configured limit with no log line and no way
back.
Separate the two. A missing binary still means "absent" and the cleanup runs as
before; a binary that exists but will not run is reported as unknown, leaves the
configured values untouched, logs why, and does not record the seeder, so the
next start decides again.
* test(database): cover fail2ban reset safeguards
---------
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(outbound): import Hysteria2 salamander from standard obfs params
The outbound share-link importers only reconstructed salamander from the
private fm=<json> finalmask dump. Every standard Hysteria2 link — and this
panel's own generator (internal/sub) since it stopped emitting fm= — carries
the obfuscation as the standard obfs=salamander & obfs-password=<pw> pair,
which the importers ignored. As a result, importing a normal Hysteria2 link
(pasted into the outbound form or pulled from a subscription) silently dropped
the salamander config and produced an outbound that negotiates plain QUIC
against a server expecting obfuscation.
Parse the standard obfs/obfs-password pair in both the Go importer
(internal/util/link, used by subscription + JSON import) and the frontend
form parser (outbound-link-parser.ts), folding it into finalmask.udp. A
salamander mask already supplied via fm= still wins, so 3x-ui→3x-ui links
are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(outbound): address review — mport hop, password-less fm mask, tests
Follow-up to the automated PR review on #6166:
- Import the Hysteria2 UDP port-hopping range from the standard `mport`
param (finalmask.quicParams.udpHop.ports) in both importers — the same
class of gap as salamander: the subscription generator emits `mport`
standalone and no `fm=`, so port hopping was silently lost on import.
An `fm=`-supplied udpHop still wins.
- When `fm=` carries a salamander mask without a usable password, fill it
in from the obfs pair instead of treating the empty mask as authoritative
(would otherwise enable obfuscation with an empty password).
- Trim the duplicated rationale comments to two lines each.
- Tests: collapse the four per-case Go functions into table-driven
subtests; cover the obfs_password/obfsPassword aliases, case-insensitive
obfs value, append-onto-non-salamander-udp, password-less-fm fill, and the
mport paths; assert the fm-wins masks stay length 1 in both suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.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.