- New GET /panel/api/inbounds/getSubLinks/:subId and /getClientLinks/:id/:email
return the same protocol URLs the panel UI's Copy button emits, honouring
X-Forwarded-Host / X-Forwarded-Proto. Documented in the API docs page.
- Refactor: sub package no longer imports web. The embedded dist FS is
injected via sub.SetDistFS, and the link generator is registered with the
service layer via service.RegisterSubLinkProvider, avoiding the circular
import the new endpoints would otherwise introduce.
- Security: stop emitting window.X_UI_CUR_VER on login.html and drop the
visible version chip from the login page, so the panel version is no
longer pre-auth info disclosure. Authenticated pages still receive it.
- Bump config/version.
New /panel/api-docs route with a one-page reference covering every
/panel/api/* endpoint (Auth, Inbounds, Server, Nodes, Custom Geo,
Backup) plus a Bearer-token primer that reads the current token and
exposes Show/Copy/Regenerate inline. Sidebar gets an API Docs entry
right after Xray; the menu label is shared via menu.apiDocs across all
13 locales.
Different nodes are different machines, so same port + transport across
NodeIDs shouldn't conflict. resolveInboundTag now keeps a caller-supplied
unique tag verbatim so central and node panels stay in agreement instead
of regenerating into a UNIQUE constraint failure on sync.
New installs land on plain dark instead of ultra-dark. The cycle button
icon now has an explicit colour so it stays visible inside the mobile
drawer (the previous color:inherit didn't cascade through the teleported
node), and hover/focus matches the menu's blue across sidebar, login,
and sub pages.
The desktop sider stretched to match the page height, so below lg
(992px) where dashboard cards stack into one column the collapse
trigger plus Logout slid off-screen. Pin the sider with
`position: sticky; height: 100vh; align-self: flex-start` so the chrome
stays viewport-tall. Split the menu into `.sider-nav` (flex: 1,
scrollable) and `.sider-utility` so Logout sits directly above the
48px trigger reserved by padding-bottom.
Replace the `<ThemeSwitch>` a-sub-menu with a single inline icon
button next to the '3X-UI' brand (sun / moon / moon+star SVG). One
click cycles Light -> Dark -> Ultra Dark -> Light. ThemeSwitch.vue
removed since it is now inlined.
Override AD-Vue dark Menu selected + hover/active state on the
sider-nav, sider-utility, and drawer menus to use the same light-blue
tint AD-Vue's light theme uses (rgba(64,150,255,0.2) / #4096ff). The
default dark variant was too subtle against #252526, so the current
page and Logout-on-hover barely distinguished themselves.
DelClient rejects the removal that would leave an inbound with zero
clients (the constraint exists because Xray protocols need at least
one client to keep the inbound functional). The bulk-delete flow
fired one DelClient call per picked client in a loop, so picking
every client meant the final iteration always errored out with
"no client remained in Inbound" and surfaced as a red toast even
though N-1 deletions had already gone through.
Now confirmBulkDelete detects the "all selected" case up front,
drops the last client from the request, and surfaces the partial
operation in the confirm dialog ("N-1 / N — last selected will
remain. Delete the inbound to remove all."). The pre-existing
single-row delete path and partial-selection bulk delete paths are
untouched. If the only client in the inbound is selected, a
Modal.warning explains the constraint instead of asking for confirm.
Frontend (NordModal.vue):
- Server selector gets show-search with the option label set to
`${cityName} ${name} ${hostname}` so admins can find a specific
server inside a 100+ entry country list by typing.
- Each option renders the load as a colored a-tag (green <30%,
orange 30-70%, red >70%) instead of plain text — quicker visual
scan when sorting through servers in the dropdown.
Backend (nord.go):
- GetCountries / GetServers now check resp.StatusCode and return
"NordVPN API error: <status>" on non-200, matching the pattern
GetCredentials already used. Previously a 4xx/5xx body was
returned as a "success" string and the frontend silently failed
to parse it, surfacing only as an empty "No servers found".
- GetCredentials drops its own ad-hoc 10s http.Client and reuses
the shared nordHTTPClient (15s) — one client, one timeout.
- ClientRowTable now applies the General-Settings pageSize to its
expanded client list. The 3.0 rewrite dropped pagination, so users
with thousands of clients per inbound hit a 30-60s browser hang on
expand (#4233).
- ID column was marked responsive: ['xs'] so it was hidden on desktop;
removed the restriction so it shows as the first column everywhere.
- Remark column is now omitted entirely when no inbound has a non-empty
remark, matching the existing Node-column pattern.
`14. Restart Xray` failed on Alpine with `systemctl: command not found` —
restart_xray was the only service action missing an Alpine branch. While
fixing it, the OpenRC reload action was passing the pidfile path to `kill`
instead of the PID inside it, so `rc-service x-ui reload` would have
failed too.
- service.TestOutbound now dispatches on `mode`:
- "tcp": parallel net.DialTimeout to every server/peer endpoint
(vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up,
no semaphore — safe to run concurrently across outbounds.
- "http" (default): existing temp-xray + SOCKS path, now with an
httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB)
alongside the total delay and status code.
- testSemaphore renamed to httpTestSemaphore — only HTTP probes
serialise, TCP runs free.
- TestOutboundResult carries the per-mode extras: timing fields for
HTTP, per-endpoint dial list for TCP, plus a `mode` echo.
- Controller reads `mode` from the form and passes it through.
- useXraySetting: testOutbound accepts mode (default "tcp"); new
testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP,
1 for HTTP) and skips blackhole / loopback / blocked outbounds —
also skips freedom / dns under TCP since they have no endpoint.
- OutboundsTab: TCP/HTTP radio toggle and a Test All button land in
the toolbar; the per-row ⚡ now uses the selected mode. Results
surface in a popover with the full timing breakdown plus the
endpoint list for TCP probes. Latency header replaces the duplicate
"check" column title.
Practical effect: testing ten outbounds in TCP mode drops from ~50–100s
(serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the
authoritative probe and now shows where the latency actually lives.
- ClientBulkModal: add `comment` and VLESS `reverseTag` fields so the
bulk-add modal can set them on every generated client (matching the
single-client form)
- ClientRowTable: add multi-select checkboxes (desktop + mobile) with a
tri-state select-all and a sticky bulk-action bar; emits a new
`delete-clients` event so the parent can wipe the picked clients in
one go. Hidden entirely when the inbound has only one client (the
last one must stay)
- ClientRowTable: new "Remained" column shows live remaining quota
per client (∞ for unlimited, red when depleted)
- InboundInfoModal: Remained cell now shows the ∞ tag when the client
has no totalGB limit, matching how Total Usage already renders it
- InboundsPage: add Online tag (+ per-bucket popovers listing client
emails) to the summary card so it mirrors the per-inbound row, and
wire an `onDeleteClients` handler that loops the existing single-
delete endpoint then refreshes once
- InboundList: forward the `delete-clients` event; hide empty remarks
on both the desktop table (custom #bodyCell) and the mobile card
- useInbounds: aggregate an `online` email list across all inbounds
so the summary popover has data to render
Adds a 4th choice to the install-time SSL prompt for users who terminate
TLS elsewhere (nginx, Caddy, Traefik) or only reach the panel through an
SSH tunnel — closes#3802.
- Option 4 prints a clear warning, then optionally binds the panel to
127.0.0.1 via `x-ui setting -listenIP` so it's unreachable from the
public internet
- When the user binds to 127.0.0.1, print the same SSH port-forwarding
command set that x-ui.sh's SSH_port_forwarding() already shows, so
remote access is one ssh -L away
- Track SSL_SCHEME so the final "Access URL:" line shows http:// when
SSL is skipped, instead of misleadingly advertising https://
- Soften the section header from "(MANDATORY)" to "(RECOMMENDED)" and
print "SSL Certificate: Skipped" when option 4 is chosen
- Rework the SSL menu copy to a parallel "verb — what (constraint)"
shape with a single Tip line focused on option 4's risks
- Migrate SubPage, QrPanel and TwoFactorModal from a QRious canvas to
<a-qrcode type="svg">, which renders the QR matrix as crispEdges
SVG rectangles — pixel-perfect at any display size or DPR, no more
white scan-line artifacts from non-integer canvas scaling
- Drop the now-unused qrious dependency and its manualChunks entry
- Default the panel to ultra-dark on first load (existing user
preferences in localStorage are preserved)
- Let the sub controller read subpage.html from web/dist/ first and
fall back to the embedded copy, so Vite rebuilds in dev no longer
require a Go recompile to refresh the asset hashes
- Swap navy dark palette for VS Code Dark+ neutrals (#1e1e1e/#252526/
#2d2d30) across theme tokens, page backgrounds and DateTimePicker
- Add brand header to the mobile drawer and desktop sider, and recolor
the drawer body so it reads as one panel with the menu
- Redesign login page with a centered card, cycling Hello/Welcome
headline and per-theme animated gradient-blob backgrounds
The Vue3 migration dropped the Observatory / Burst Observatory section
that used to sit under the balancer table. Without it, leastPing /
leastLoad strategies had nowhere to populate Xray's required
subjectSelector, so balancers that depended on probe data silently
ran with an empty observer config.
- Auto-seed and sync `observatory` for leastPing balancers and
`burstObservatory` for leastLoad balancers (subjectSelector
recomputed from every matching balancer's selector list). Drops
the observatory when no matching strategy remains.
- Defaults (probeURL, interval, connectivity, sampling) match the
values the legacy panel shipped, themselves taken from the Xray
docs at xtls.github.io/config/{observatory,burstobservatory}.html.
- Surface both observatories under the table as a radio-switched
JSON textarea so admins can tune probe settings inline without
dropping into the full xray template tab.
Rename the SPA globals injected by Go to drop the ad-hoc dunder shape
and free up the bare `webBasePath` name (still the DB setting key)
from colliding with the JS global it used to share:
window.__X_UI_BASE_PATH__ -> window.X_UI_BASE_PATH
window.__X_UI_CUR_VER__ -> window.X_UI_CUR_VER
Also rework the QR-Code modal to fold every QR (subscription + JSON
sub URL, share links, WireGuard config/peer links) into a single
a-collapse with one panel per QR. Subscription panels are listed
first and open by default; everything else stays collapsed so a
multi-link inbound no longer scrolls forever.
A blank encryption field caused Xray to reject the outbound config with
'VLESS users: please add/set "encryption":"none"'. Default the
constructor parameter, coerce empty values, and final-guard toJson so
every code path emits a valid encryption value.
Fail2ban parses % as variable interpolation in action.d configs, so the
unescaped %Y/%m/%d %H:%M:%S in the date command crashed fail2ban on
startup. Double the %s in the heredoc so the rendered action file
contains %% and fail2ban collapses it back to a literal % when invoking
the shell command.
node:22-alpine has no manifest for linux/arm/v6, breaking multi-arch
builds. Frontend output is static JS/CSS that doesn't need to be
built per target arch — pin the stage to $BUILDPLATFORM so Vite
always runs on the host. Also drop `install: true` from
setup-buildx-action@v4 (input was removed).
- DNS server modal: rename expectIPs -> expectedIPs (per docs); add
per-server tag, clientIP, serveStale, serveExpiredTTL, timeoutMs;
flip skipFallback default to false; hydration still accepts legacy
expectIPs for back-compat.
- DNS tab: add hosts editor (domain -> IP/array), serveStale +
serveExpiredTTL controls, "Use Preset" button bringing back the
legacy preset gallery (Google / Cloudflare / AdGuard + Family
variants — fixed AdGuard Family IPs that were wrong in legacy),
and a "Delete All" button to wipe the server list at once.
- i18n: add 15 new dns.* keys across all 13 locales.
- Frontend-wide formatter pass on Vue components (whitespace and
attribute layout only, no behavior changes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Traffic-writer single-consumer queue (web/service/traffic_writer.go)
serialises every DB write that touches up/down/all_time/last_online
(AddTraffic, SetRemoteTraffic, Reset*, UpdateClientTrafficByEmail) so
overlapping goroutines can no longer clobber each other's column-scoped
Updates with a stale tx.Save.
- DB pool: WAL + busy_timeout=10s + synchronous=NORMAL + _txlock=
immediate, MaxOpenConns=8 / MaxIdleConns=4. The immediate-tx PRAGMA
fixes residual "database is locked [0ms]" cases where deferred-tx
writer-upgrade conflicts bypass busy_timeout.
- SetRemoteTraffic full-mirrors node-authoritative state into central:
settings JSON, remark, listen, port, total, expiry, all_time, enable,
plus per-client total/expiry/reset/all_time. Inbounds and
client_traffics rows present on node but missing from central are
created; rows missing from snap are deleted (with cascading
client_traffics removal).
- NodeTrafficSyncJob detects structural changes from the mirror and
broadcasts invalidate(inbounds) so open central UIs re-fetch via REST
on node-side add/del/edit without manual refresh.
- XrayTrafficJob broadcasts invalidate(inbounds) when auto-disable flips
client_traffics.enable so the per-client toggle reflects depletion
without manual refresh.
- Frontend: inbounds page now subscribes to the BroadcastInbounds 'inbounds'
WS event (full-list pushes from add/del/update controllers were silently
dropped). Fixes invalidate payload field (dataType -> type). Restart-
panel modal switched from Promise-wrap to onOk-only so Cancel actually
cancels.
- Node files trimmed of stale prose-comments; cron cadence dropped
10s -> 5s to match the inbounds page UX.
- README badges and Go module path bumped v2 -> v3 to match module rename.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Several file operations used os.ModePerm (0777) which makes files
world-writable and world-readable, violating the principle of least
privilege:
- database/db.go: InitDB directory creation → 0755
- xray/process.go: Xray config write → 0644
- xray/process.go: Crash report write → 0644
- web/service/server.go: Binary extraction → 0755
Also removes unused "io/fs" imports from the affected files.
The runSeeders function in database/db.go had three database operations
whose errors were silently ignored:
1. Pluck("seeder_name", &seedersHistory) - if this fails, the seeder
might re-run and double-hash already bcrypt'd passwords, corrupting
them
2. Find(&users) - if this fails, no users get migrated but the seeder
still marks itself as complete
3. Update("password", hashedPassword) - if this fails for a user, their
password silently remains in the old format
All three now properly check and return errors with descriptive messages.
In the Xray settings update handler, the error from
SetXrayOutboundTestUrl was silently discarded. If the database write
failed, the user received a success toast ("Settings updated
successfully") but the outbound test URL was not actually saved.
Now properly checks the error and returns a failure response to the
user, consistent with how the preceding SaveXraySetting call is
handled.
The sidebar theme submenu (Theme / Dark / Ultra dark) and the dashboard's
Xray status badge ("Xray is running" etc.) were hardcoded English strings.
Wire them through vue-i18n: ThemeSwitch.vue uses menu.theme/dark/ultraDark,
and XrayStatusCard.vue derives the badge text from the existing
pages.index.xrayStatus{Running,Stop,Error,Unknown} keys (status.js no
longer carries an English stateMsg field).
The "Nodes" menu item was already keyed as menu.nodes but only en-US and
fa-IR had a translation; add it to the other 11 languages, matching the
wording each file already uses for pages.nodes.title.
#4201
Switch the inbounds-page modals, login page's theme switch, and the
Persian date picker to defineAsyncComponent. They're not needed on
first paint, so deferring them shrinks the initial bundle and lets
the LCP element render sooner.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Node model only carried `json:` tags, so when the panel's axios
posted form-encoded bodies to /panel/api/nodes/add and /test, Gin's
form binder produced a zero-valued Node — empty Name, empty Address,
Port=0 — surfacing as "node name is required" and a probe URL of
"https://:0/...". Add `form:` tags so add/test bind correctly.
Also skip inbounds with NodeID set when building the central xray
config; otherwise the central panel tried to listen on ports owned by
node-managed inbounds and xray-core failed to start with a bind
collision.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Show the per-client pane as the default tab and fold the subscription
URLs into its bottom (under a divider) so the modal has two tabs
instead of three. Inbound details move to the second tab and remain
the default fallback for protocol-only entries (HTTP/Mixed/Tunnel/
WireGuard) that have no per-client view.
Replace the cramped <a-table> on <768px with a stacked card list for
both inbounds and the per-client expanded rows. Each card surfaces
protocol, port, node, traffic, all-time traffic, client count and
expiry inline as labeled rows instead of hiding them behind popovers,
fixes the 0px gutter that made cards visually merge, and softens the
in-quota green from #52c41a to #389e0a (Ant green-7) so traffic tags
are no longer blinding on dark themes.
InboundFormModal forms specified label/wrapper cols only at md
(>=768), leaving 576-767 unset and breaking the grid in that range.
Move the breakpoint down to sm so the desktop 8/14 split applies
from 576 upward.
SettingListItem had its breakpoints inverted: at <992 no span was
set so the meta and control cols squeezed side-by-side, and at lg
(992-1199) they stacked. Switch to xs/lg so input stacks below the
text under 992 and sits beside it from 992 upward.
Replace the unbounded http.Get used by GetXrayVersions with a 10s-
timeout client so a slow or unreachable GitHub can't hang the Xray
Updates modal. Bump the controller cache from 60s to 15 minutes,
and on a request error fall back to the last successful list when
one is available.
Both index-page log modals (panel logs and xray access logs) now
adapt to narrow viewports and dark / ultra-dark themes:
- Render through Vue templates instead of v-html — drops the manual
escapeHtml helper and the regex-based string formatting; each line
is parsed once into structured fields (date, time, level, body for
panel logs; from / to / inbound / outbound / email for xray logs).
- Mobile: stacked cards per entry. Panel-log cards show time + a
level badge above the wrapped message; xray-log cards show time
and event tag above the From → To pair, with inbound / outbound /
email as small meta pairs below. Long IPv6 / hostnames wrap
instead of overflowing.
- Modal goes full-bleed on mobile (100vw, no rounded corners,
pinned to viewport height) so cards get full width.
- Toolbar wraps cleanly when the row-count, level, syslog checkbox,
and download button can't fit on one line.
- Theme-aware colour palette via CSS variables on .log-container —
brighter shades on body.dark and [data-theme="ultra-dark"] so
level text and blocked / proxy rows keep AA contrast against the
navy and near-black surfaces.
- Cards render flush on the container surface (no separate card bg)
so the colour story is identical to the desktop view.
The Vite SPA reads locale JSON via a glob that resolves to
<repo>/web/translation/*.json, but the frontend build stage only
copied frontend/, so the production bundle shipped with no messages
and the Docker panel rendered untranslated keys. Copy the directory
into the frontend stage at the path the glob expects, and into the
final image so the Go disk fallback in locale.loadTranslationsFromDisk
also has somewhere to read from.
DNS outbound now mirrors xray-core's documented shape: rewriteNetwork
/ rewriteAddress / rewritePort / userLevel replace the legacy network
/ address / port keys, and unset values are dropped on the wire. Old
configs are still accepted on read so saved configs migrate cleanly.
While there, fix two latent bugs in repeat-item editors (DNS rules,
Freedom noise, WireGuard peers):
- The "+" buttons pushed plain objects into arrays of class instances,
so toJson() crashed on the next read and the JSON tab silently went
blank. Push proper class instances instead.
- Each item heading lived outside any a-form-item, so the delete icon
ignored the form's column grid and slumped left. Wrap the heading
in a form-item with the standard offset wrapper-col and switch the
flex to space-between so the icon sits at the right of the input
column, in line with the fields below it.
#4185
Surface xray-core's loopback outbound in the Outbounds form so users
can re-route already-processed traffic back into a named inbound for
secondary routing (e.g. splitting TCP/UDP from one ingress). The
inboundTag field is an autocomplete over existing inbound tags, with
free-text fallback for inbounds defined outside the panel. Loopback
outbounds are excluded from the connectivity test since they have no
network endpoint.
Outbound reverse tags now appear as inbound options in routing rules
(#4199), and inbound-client reverse tags appear as outbounds in the
balancer selector (#4187). Both represent virtual endpoints created by
xray-core that the dropdowns previously missed.
- Vite dev server reads webBasePath from x-ui.db via node:sqlite and
injects __X_UI_BASE_PATH__ on every HTML serve, mirroring dist.go.
Single broad proxy regex catches backend routes whether the URL is
prefixed or not, and the bypass serves login.html for the bare
basePath URL so post-logout navigation lands on Vite's own page
instead of the production dist HTML's hashed asset URLs.
- axios.defaults.baseURL is set from __X_UI_BASE_PATH__ at startup so
HttpUtil calls reach the backend's basePath group instead of 404ing
on every prefixed install. fetch() for the public CSRF endpoint
prepends the prefix manually since it doesn't honor axios defaults.
- Logout/redirect responses set Cache-Control: no-store and the index
handler's logged-in redirect uses an absolute base_path+panel/ URL,
preventing browsers from replaying a stale cached 307 that bounced
the user back to /panel/ after logout.
- ClearSession also issues a Path=/ deletion cookie when basePath is
not "/", so a legacy cookie from an earlier basePath setting can't
keep IsLogin returning true after logout.
- getPanelUpdateInfo no longer returns a translated error message on
GitHub fetch failures, so HttpUtil's auto-popup stays quiet on
offline / blocked environments.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>