Removes the `virtual` table option from Clients, Inbounds, and Nodes list pages. This aligns table behavior across key admin views and avoids virtualization-related rendering/interaction issues with the existing scroll and pagination setup.
* 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>
- routes.tsx, LazyMount.tsx: replace Suspense fallback={null} with Spin
loader so page transitions and lazy modals never show blank content
- useMediaQuery.ts: switch from resize event to matchMedia change event,
eliminating state updates on every pixel drag; export MOBILE_BREAKPOINT_PX
- SubPage.tsx: drop duplicate inline isMobile logic (7 lines), use shared
useMediaQuery(576) (2 lines)
- ClientsPage, InboundList, HostList, NodeList: add virtual + scroll.y to
Table for viewport-only DOM rendering of large datasets
* 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>
Refresh the Go toolchain from 1.26.5 to 1.26.6 and update the related x/* and protobuf dependency set in go.mod/go.sum. This keeps the project aligned with the current patch releases and ensures the module graph matches the expected transitive versions.
* fix(clients): stop recomputing the summary badges from the client_stats snapshot
pickClientsSummary's coverage guard (serverSummary.total >
allClientStats.length) only catches a net shortfall: an orphaned
client_traffics row and a client still missing one can cancel out, or an
orphan surplus alone can pass uncaught, and either way the guard fails to
fall back (#6116).
client_paging.go's q.summary() already derives the same bucket counts with
clients as the driving table (LEFT JOIN client_traffics), so it cannot
miscount either shape regardless of how the row got there, and listQuery
already polls it every 5s — the same cadence client_stats ticks on. The
client-side recompute bought no fresher a number than the server already
provides on its own poll, only a window to get one wrong, so this drops it:
the summary badges now always read serverSummary directly. allClientStats,
computeClientsSummary, pickClientsSummary and sameSummaryInputs are removed
as dead code along with it; the per-row live traffic patch in
applyClientStatsEvent is untouched, since it reads the same snapshot by
email match rather than by count and was never exposed to this class of bug.
* fix(clients): force a refetch on window focus and drop a stale comment
Review feedback on PR #6169:
listQuery combines staleTime: Infinity with refetchInterval: 5000, which
pauses while the tab is hidden. The WS-driven per-row traffic patch in
applyClientStatsEvent has no such visibility gating, so on a background tab
a row's live numbers keep moving while the summary badges above them freeze
at whatever they were before the tab was hidden, and staleTime: Infinity
blocks refetchOnWindowFocus from closing that gap on return. Before this
PR the client-side recompute this branch removed happened to paper over the
same underlying gap; now that it's gone, the gap is directly visible.
refetchOnWindowFocus: 'always' forces exactly one refetch on refocus,
ignoring staleTime, without touching the interval/staleTime pairing that
governs the rest of this query's behavior.
Separately, useInbounds.ts still referenced computeClientsSummary by name
in a comment explaining bucket priority; that function no longer exists
after this PR. Dropped the comment rather than repoint it, per the repo's
no-//-comment convention.
* 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(install): preserve custom bin/ files (e.g. hand-added geoip) across updates
Every reinstall/update wipes /usr/local/x-ui/ wholesale and re-extracts
the release tarball, which only ships known assets (xray/mtg binaries,
the bundled geoip*/geosite*.dat sets). A user-reported real incident:
a hand-placed custom geoip file referenced from a routing rule via
"ext:<file>:<code>" got silently deleted on update, and Xray refused
to start at all afterward ("failed to open <file>: no such file or
directory"), taking down every inbound until the file was manually
restored from the user's own backup.
install_x-ui now backs up the old bin/ before the wipe and restores,
after extraction, only the files the fresh release doesn't provide --
bundled assets still get the newer per-release copy, nothing custom
silently disappears. Verified in isolation: standard files (geoip.dat,
the xray binary) end up as the fresh release's copy; a custom file
absent from the release survives untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: harden the bin/ snapshot-and-restore against the review round on #6152
- Replace the mktemp+cp snapshot with a same-filesystem mv of bin/ aside:
an unchecked mktemp failure previously made the very next line copy
bin/'s contents into "/" (empty custom_bin_backup + trailing slash),
and a silently-ignored cp failure (stderr redirected, exit code never
checked) could leave a truncated custom geo file that gets "restored"
as if it were intact. A rename is atomic and needs no extra disk space,
removing both failure modes at once; if it fails, back off cleanly and
say so instead of proceeding as if a backup exists.
- Add a trap so an interrupted update (Ctrl-C, signal) between the
backup and the restore doesn't leave the snapshot (which contains
bin/config.json and every mtproto client's FakeTLS secret) sitting
around indefinitely; the two exit-path cleanups this replaces are gone
since the trap now covers those exits too.
- Move the restore below the arm arch-rename/chmod block instead of
before it, so xray-linux-arm32/mtg-linux-arm already exist under their
final names and don't get needlessly restored-then-overwritten and
misreported as "custom".
- Exclude bin/config.json and bin/mtproto/*.toml from the restore: those
are the panel's own generated runtime state (internal/xray/process.go,
internal/mtproto/manager.go), not admin-placed files, and restoring a
stale one only resurrects dead state or recreates bin/mtproto/ with the
wrong (more permissive) directory mode.
- Match symlinks in the restore's find, not just plain files -- cp -a
already preserves them in the snapshot, but the restore loop was
silently dropping them, which is exactly the failure mode (a geo file
symlinked in from elsewhere) this PR set out to fix.
- Quote the two new xui_folder expansions.
- Extend the non-interactive smoke test to reinstall over an existing
install with a sentinel file in bin/, asserting it survives and that
the bundled geoip.dat is still the release's own copy -- the update
path this PR touches had no CI coverage at all before this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Update fumadocs-core/mdx/ui to 16.14.3, lucide-react to 1.31.0, @types/node to 26.2.0, typescript-eslint to 8.67.0, esbuild to 0.28.2, shiki to 4.4.3, and various other transitive dependencies.
* 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>
Collapse animates opacity in over motionDurationMid; the Collapsed story's
play function only waited for visibility, so the addon-a11y color-contrast
check could sample a mid-fade, lower-contrast frame and fail flakily in CI.
Wait for the panel's opacity to settle to 1 first.
Keep swagger-ui-react and its transitive dependencies in the lazy swagger chunk so the initial panel bundle stays smaller. This avoids eager loading the OpenAPI UI on first paint while keeping the API docs route unchanged.
Refresh frontend package versions and regenerate the lockfile. This updates core UI and tooling packages including Ant Design, React Hook Form, Storybook, Vite, eslint/typescript-eslint, @noble/hashes, persian-calendar-suite, and swagger-ui-react to pick up the latest fixes and minor improvements.
Move html/body shell and global css to root app layout to avoid hydration/script warnings from nested document nodes. Disable provider theme injection and add a custom script-free theme switch in shared layout slots.
Also migrate docs search static client initializer to ZBSearch (initDB), add zbsearch dependency, and align docs lint tooling with ESLint 9 compatibility so npm run lint passes.
npm audit --omit=dev --audit-level=high is a CI gate and it currently fails on
main: swagger-ui-react pulls @swagger-api/apidom-reference, which pins
minimatch, which resolves brace-expansion to 5.0.8 — the range covered by
GHSA-rgw5-rvv9-x895.
Pin the patched 5.0.9 through the existing swagger-ui-react overrides block
rather than globally: minimatch@3 under eslint-plugin-jsx-a11y still needs the
1.x line, and a blanket override would force v5 there too.