echConfigList was stored under tlsSettings.settings but the share-link
and JSON-subscription generators only read fingerprint and
pinnedPeerCertSha256 from that bag, silently dropping ECH from VLESS,
Trojan and VMess links. Read echConfigList alongside them and flatten it
into tlsSettings.echConfigList for the JSON subscription.
Closes#4933
* fix(node-traffic): restart remote xray after disabling clients to kill active sessions
When a client's traffic limit is reached on a remote node, the panel pushes
enable=false to that node via UpdateInbound. The node calls RemoveUser on its
local xray, which blocks new connections but leaves any already-established TCP
session alive. The user could continue browsing/downloading until they
disconnected voluntarily.
Fix: after successfully pushing a client disable to a remote node, call
RestartXray on that node. This mirrors what already happens for the local node
when the "Restart Xray on client disable" setting is enabled (default: on),
and ensures active sessions are terminated immediately on all nodes where the
client was disabled.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(node): restart remote xray after tx commit, not inside it
Move the remote RestartXray calls out of the addTraffic write
transaction. disableInvalidClients now returns the affected remote
node IDs instead of restarting their xray while the SQLite write lock
is held; AddTraffic performs the restart after the transaction commits
via restartRemoteNodesOnDisable. Avoids holding the serialized write
lock across slow per-node restart RPCs.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
When a remote node syncs traffic back to the panel, the UPDATE in
setRemoteTrafficLocked wrote cs.Enable directly into client_traffics.enable.
If a snapshot carrying enable=true arrived after the central panel had already
set enable=false (due to the client reaching their traffic limit), it silently
re-enabled the client — letting them consume 2-3x their allotted quota before
the next disable cycle caught up.
Fix: replace the unconditional SET enable = ? with a CASE expression that only
allows a disable (0->0), never a re-enable (0->1). The central panel remains
the sole authority for turning a client back on.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(sub): add finalmask support to JSON subscriptions
* feat(sub): modern xray JSON format with unified finalmask editor
Drop the legacy JSON subscription format entirely and always emit the
modern xray shape:
- Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/
shadowsocks; hysteria was already flat.
- Express fragment/noise via streamSettings.finalmask instead of the
legacy direct_out freedom dialer + dialerProxy sockopt.
The global finalmask (tcp/udp masks + quicParams) is stored as a single
setting (subJsonFinalMask) and merged into every generated stream,
replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams
settings.
Reuse the existing FinalMaskForm (used by inbound/outbound) for the
settings UI via a small bridge component; add a showAll prop so all
TCP/UDP/QUIC sections render for the global case. This supersedes the
hand-rolled Fragment/Noises/quicParams tabs with the full mask editor
(all mask types).
Note: this is a breaking change — JSON subscriptions now require a
recent xray client on the consumer side.
* fix
---------
Co-authored-by: biohazardous-man <biohazardous-man@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* feat(clash): add routing rules and enable routing option for Clash/Mihomo subscriptions
Allows adding custom YAML blocks and placeholders to Clash exports.
Why: Shifting routing to the client prevents server IP exposure for
DIRECT traffic and reduces unnecessary server bandwidth/CPU usage.
* fix
---------
Co-authored-by: Misfit-s <>
Telegram group chats can contain multiple bots. Commands addressed to another bot, such as /status@other_bot, should not be handled by the 3x-ui bot.
Closes#4893
Drive every client/inbound/group endpoint at 100k-200k clients on PostgreSQL and fix the latent issues found in previously-unbenchmarked paths:
- enrichClientStats: chunk the email IN lookup (was an unchunked bind that crashed past 65535 clients without traffic rows, taking down GetInbounds/GetInboundDetail/GetAllInbounds)
- GetOnlineClients: add the missing nil-process guard its siblings already have, so ListPaged no longer panics before xray starts
- GetClientTrafficByEmail: read UUID/subId from the indexed clients table instead of parsing the inbound's full settings JSON (439ms to ~1.5ms, flat in N)
- BulkResetTraffic: replace the per-email serialized loop with one chunked bulk UPDATE in a single transaction
- DelDepleted: delegate to the already-batched BulkDelete instead of deleting each depleted client one by one
Adds a postgres-gated full endpoint sweep plus an A/B benchmark, and SQLite correctness tests for the changed methods.
Bulk client operations bound their entire working set in a single
WHERE x IN (...) clause, which exceeds PostgreSQL's 65535-parameter limit
(and SQLite's 32766) and gives the planner a pathological query, so they
failed outright on inbounds/selections larger than the limit. Every such
query is now chunked at 400 items:
- BulkDelete / delete-all-clients: six IN queries chunked, and the
per-row delete tombstone (which swept the whole in-memory map on every
call, O(N^2)) replaced with a single bulk sweep.
- BulkAdjust: record and inbound-mapping lookups chunked.
- AddToGroup / RemoveFromGroup (bulk add/remove to group): three IN
queries chunked.
- replaceGroupValue (rename/delete group): inbound-mapping lookup chunked.
- List (all-clients listing): link and traffic lookups chunked.
Measured on PostgreSQL 16: delete-all-clients on a 100k-client inbound
now completes in ~7s (previously crashed at the parameter limit); bulk
add/remove to group ~6s and full client list ~1s at 100k.
sync_scale_postgres_test.go adds skip-gated benchmarks for delete-all,
group add/remove, and list.
Follow-up to the SyncInbound bulk rewrite, fixing the remaining O(M*N)
and O(M)-round-trip behaviour in the add/delete and bulk paths that made
them time out on large inbounds (worst case minutes), especially on
PostgreSQL.
- compactOrphans: chunk the "email IN (...)" lookup (400/batch) instead
of binding every email at once. A single huge IN exceeded PostgreSQL's
65535-parameter limit (and SQLite's) and made the planner pathological,
so add/delete failed outright past ~100k clients.
- emailsUsedByOtherInbounds: new batched form used by delInboundClients
(BulkDetach) and bulkDelInboundClients (BulkDelete), replacing a
per-email global JSON scan (O(M*N)) with one scan, and skipped entirely
when keepTraffic is set.
- BulkCreate: rewritten to validate/dedup in one pass, then group clients
by inbound and add them in a single addInboundClient call per inbound
(one getAllEmailSubIDs, one settings rewrite, one SyncInbound) instead
of running the full single-create pipeline per client.
- Bulk delete/adjust: batch DelClientStat/DelClientIPs with IN deletes
and wrap the settings Save + SyncInbound in one transaction, so the
per-row writes share a single fsync instead of one per row.
Measured on PostgreSQL 16 (one inbound, M=2000 affected clients):
- create: 8m35s (M=500) -> ~1-5s
- detach: 52s -> ~4s (flat in N)
- delete: ~16s -> ~1-4s
- adjust: ~20s -> ~7-10s
add/delete of a single client on a 200k-client inbound stays in seconds.
sync_scale_postgres_test.go adds skip-gated benchmarks (XUI_DB_TYPE=
postgres) for the single add/delete and the five bulk operations.
Every client mutation funnels through SyncInbound, which ran O(n) DB
round-trips per call: one SELECT per client, a Save+UpdateColumn per
client, and a per-row junction INSERT. Toggling a single client on a
large inbound issued thousands of queries and timed out, badly so on
PostgreSQL where each round-trip pays TCP latency.
SyncInbound now:
- loads existing records with a single chunked SELECT ... email IN (...)
instead of one query per client
- writes only the records that actually changed (skips no-op Saves), so
toggling/editing one client writes one row, not all of them
- batch-creates new records and batch-inserts the junction rows
Merge and sticky-field semantics are unchanged. Measured on PostgreSQL
16: a single-client toggle on a 50k-client inbound drops from ~8m54s to
~0.9s, and seeding 50k clients from ~2m48s to ~1.6s; 200k clients sync
in seconds.
A skip-gated benchmark (web/service/sync_scale_postgres_test.go, run
with XUI_DB_TYPE=postgres) reproduces and verifies the scaling.
- Cleanup on issuance/install failure now also removes the acme.sh
${domain}_ecc (and ${ip}_ecc) directory, not just ${domain}, so a
failed run no longer leaves partial state behind.
- The 'existing certificate' check only reuses a cert when its
fullchain.cer and key files are actually present and non-empty;
otherwise the broken state is removed and issuance proceeds. This
fixes the 0-byte fullchain.pem produced by reusing failed state.
- Menu option 5 (set cert paths) now registers the acme.sh --installcert
hook with --reloadcmd 'x-ui restart' when acme.sh knows the domain, so
auto-renewal copies the renewed cert and reloads the panel.
The 3x-ipl action used iptables-allports, so a banned IP lost all TCP
access including SSH and the panel, locking admins out (especially with
dynamic-IP clients). The ban now blocks every TCP port except the SSH
and panel ports via a multiport negation, derived at jail-creation time
in both x-ui.sh and DockerEntrypoint.sh. This keeps IP-limit working for
all current and future inbounds without per-port config.
AutoMigrate re-creates the client_traffics -> inbounds foreign key, but
the running panel drops it and tolerates client_traffics rows whose
inbound was deleted. Migrating a DB with such orphaned rows failed with
an fk_inbounds_client_stats violation. Drop the constraint on the
destination right after AutoMigrate so the copy matches runtime behavior.
UpdateUser and DeleteUser hit the node's email-based full-client endpoints, which fanned out to every inbound the client had on the node: editing a client wiped flow on the node's other inbounds, and detaching one node inbound deleted the client from all of them.
Make both inbound-scoped, mirroring AddClient. DeleteUser now detaches the resolved remote inbound id; UpdateUser passes an inboundIds scope so the node updates only that inbound.
Binary: extend the migrate-db subcommand with --dump and --restore so a
SQLite database can be exported to a portable SQL text dump and rebuilt from
one, alongside the existing --dsn PostgreSQL copy. Implemented in Go via the
bundled sqlite driver (new database/dump_sqlite.go); no external sqlite3 client
is required. Add ExportPostgresToSQLite (reverse of MigrateData) to build a
SQLite .db from live PostgreSQL data, reusing the shared copyAllModels helper.
Overview: add a "Download Migration" item to Backup & Restore plus a
getMigration endpoint/service that returns a .dump on SQLite or a .db on
PostgreSQL, so the data can seed a panel on the other backend. Document the
endpoint in api-docs and translate the three new strings across all locales.
Tests: cover the destination-side copy (AutoMigrate + copyTable into SQLite)
and the dump/restore round-trip including quoted values. Ignore *.dump.
The x-ui.sh helper that drives this from the CLI is in PR #4910.
Store API tokens as SHA-256 hashes instead of plaintext and return the token value only in the create response. List no longer exposes the token, and the UI drops the Show/Copy buttons in favor of a one-time reveal modal at creation.
Match hashes the presented bearer token before the constant-time compare, and a migration hashes any pre-existing plaintext rows in place so existing tokens keep authenticating. Docs and translations updated.
Expose level-0 connection policies in the panel's Basics tab: idle timeout (connIdle) and per-connection buffer size (bufferSize). Empty fields delete the key so Xray falls back to its own defaults. Adds en-US/fa-IR strings and types policy.levels in the Zod schema.
Expose the OCSP Stapling refresh interval (seconds) on the TLS
certificate object in the inbound security form, defaulting to 3600s
to match xray-core. Covers both file-backed and inline cert shapes.
For an inbound deployed to a node, the button read the central panel's webCertFile/webKeyFile and inserted paths that don't exist on the node, crashing the node's Xray on startup.
Add a token-accessible GET /panel/api/server/getWebCertFiles that returns a panel's own web cert/key paths, Remote.GetWebCertFiles to fetch it from a node, and GET /panel/api/nodes/webCert/:id to proxy it. setCertFromPanel now calls the node endpoint for a node-assigned inbound and the local settings otherwise, warning instead of inserting wrong paths on error/empty.
Fixes#4854
Multi-inbound clients showed online on every inbound they were attached to. Xray's user-level traffic stat aggregates across all inbounds a client belongs to, so the email signal alone can't say which inbound was used.
Pair it with the inbound-level traffic signal under the same 20s grace and gate the per-inbound rollup on it: a client only shows online on inbounds that actually moved bytes this window. Remote nodes report no per-inbound activity and stay ungated (no regression). Adds GetActiveInboundsByNode, the activeInbounds WS field and POST /panel/api/clients/activeInbounds.
Fixes#4859
Changing the transport in the outbound edit modal rebuilt streamSettings
from scratch, dropping tlsSettings (and its serverName) while keeping
security: 'tls'. On save xray received TLS with an empty SNI, so SNI-spoof
tunnels connected but passed no traffic. Carry over tlsSettings/
realitySettings when the new network still supports the security mode,
via a new applyNetworkChange helper. Fixes#4791.
- Bump go directive to 1.26.4 to pick up stdlib security fixes in
crypto/x509, mime and net/textproto flagged by govulncheck
- Add /panel/groups to the api_docs_test SPA-page allowlist so the
UI page route is not treated as an undocumented API endpoint
- go.sum carries pgx/v5 v5.10.0 bump
Sidebar is icon-only by default and expands as an overlay on hover, so the dashboard content underneath no longer reflows. Drops the persisted collapse state and the click trigger that conflicted with hover.
Custom geosite/geoip downloads built their own ssrfSafeTransport and never used the configured Panel Network Proxy, so geo updates failed on servers where GitHub is filtered. Route all custom-geo HTTP (startup probes + downloads) through panelProxy when set, falling back to the direct SSRF-guarded transport otherwise; the target URL stays SSRF-validated.
The Telegram bot only honored a socks5:// panel proxy and silently rejected http(s)://, despite the setting advertising both. Branch the fasthttp dialer (FasthttpHTTPDialer for http(s), FasthttpSocksDialer for socks5) and accept all three schemes in the fallback and NewBot validation.
Add tests proving the panel proxy is used by custom geo and that the bot dialer speaks HTTP CONNECT vs SOCKS5 per scheme.
GORM struct INSERT substitutes a column default tag for Go zero-values, so disabled rows (enable=false) silently re-enabled on the destination. Copy each batch through explicit per-column maps so every value is written verbatim. Adds a regression test.
Redesign the Add Inbound -> Stream External Proxy section into labeled per-entry cards (Force TLS / Host / Port / Remark and, under TLS, SNI / Fingerprint / ALPN) and add a Pinned Peer Cert SHA-256 field with a generate-random-hash button to each entry.
The pin flows end to end into share links: pcs for vmess/vless/trojan/ss (stripped when a proxy forces security off) and the hex-normalized pinSHA256 for Hysteria. JSON and Clash subscriptions emit the native pinnedPeerCertSha256 / pin-sha256 via the cloned stream. Adds the forceTls label across all 13 locales plus frontend and Go tests.
client_traffics.inbound_id is a legacy single-inbound pointer that goes stale when an inbound is deleted and recreated: the email-keyed traffic row survives but references a missing inbound. Code that resolved the owning inbound from it broke several client operations.
- adjustTraffics: 'Start After First Use' (negative expiry) never converted to an absolute deadline on first traffic, so the countdown never started. Now resolves inbounds via the client_inbounds link and computes the new expiry once per email so multi-inbound clients stay consistent.
- GetClientInboundByEmail / GetClientInboundByTrafficID: fall back to client_inbounds when the pointer is dead, fixing reset traffic ('record not found'), client info, and Telegram set-tgId.
- autoRenewClients: resolve renew targets via client_inbounds so scheduled renews are not silently skipped.
- clients page: allow resetting a client with no inbound attachment (the backend already zeroes counters by email).
Add regression test for the delayed-start conversion under a stale inbound_id.
- Sample swap %, TCP/UDP connection counts and disk-usage % on the host ticker
- System History: Swap overlaid on the RAM tab, plus new Connections and Disk Usage tabs
- Persist the host time-series across restarts: gob snapshot beside the DB, written on a timer and at shutdown, restored on boot
- Live-refresh the open chart (2s for short ranges, 10s for longer)
- Localize CPU/RAM/Swap and the new tab/chart titles across all 13 languages and route legend series names through i18n
- Collect disk read/write and network packet-rate metrics on the host sampler
- Sparkline: optional 2nd/3rd overlaid series with a colored legend
- System History: merge Bandwidth (up/down), Disk I/O (read/write) and Load (1m/5m/15m) into single multi-line tabs
- Add a descriptive per-chart title and mobile-only tab icons to both modals
- Localize every chart title and tab label across all 13 languages
Move the basic routing presets (block torrent/IPs/domains, direct IPs/domains, IPv4) out of the Basics page into a Basic tab in the Routing section, next to the advanced Rules table; both edit the same routing.rules so existing rules stay in sync.
Drop the WARP and Nord routing preset rows - WARP/Nord outbounds are still added from the Outbounds page and any existing rules remain editable in the Rules tab.
Hide the Source and Balancers columns in the rules table when no rule populates them.
Settings and Xray Configs are now expandable sidebar submenus that list their sections; clicking a section opens it via the URL hash (e.g. #general, #basic) and the in-page top tab bar is removed on both pages.
Within each section the collapse groups become horizontal tabs, each with an icon; on mobile only the icon shows with the label in a tooltip, via a shared catTabLabel helper used by both settings and xray.
Subscription Formats: the nested collapses in Fragment/Noises/Mux/Direct are replaced with a cleaner layout - framed field groups, and each noise is a card with a delete button plus a dashed add button.
Xray: the Reset to Default button is now a solid danger button so its hover state is visible.
Relocate Remark Model & Separation Character from the General/Panel tab to the Subscription tab's Information section, beside Show Info and Email in Remark, since it only governs how share-link remarks are composed. The sample preview uses concrete example values and renders the separator literally.
Also drop the port from the subscription page link rows so each row shows just the inbound remark; the port still appears in the client QR modal and the client info modal.
Show colored protocol/transport/security tags followed by the inbound remark and port for each share link in the client QR modal, client info modal and subscription page. The client email and the traffic/expiry decorations are stripped from the remark so only the inbound remark and port remain.
Consolidate the duplicated per-page parseLinkMeta/trimEmail/PROTOCOL_COLORS into a shared lib/xray/link-label.tsx (parseLinkParts, LinkTags, linkMetaText) so the colours and the email/stats stripping stay identical across all three surfaces.
The frontend has a groups page route and sidebar entry, but the backend
never registered a GET handler for /panel/groups. A hard browser refresh
on that page fell through to the 404 handler. Add the missing panelSPA
registration alongside the other page routes.
Fixes#4837
Two multi-inbound client bugs from issue #4834:
- Clearing a client's reverse tag never persisted: SyncInbound keeps a non-empty sticky guard on reverse (shared with node-sync/rename), so the cleared value never reached the canonical clients.reverse column the edit form reads. Update now writes that column authoritatively from the submitted client, matching how it already writes email/updated_at directly.
- Attaching a new inbound reset xtls-rprx-vision: Attach seeded its wire client from the canonical clients.flow column, which a non-flow inbound can zero during the preceding update. It now derives the flow from EffectiveFlow (the per-inbound flow_override), so flow-capable targets keep the flow and others stay empty.
Adds service tests for both paths and a guard test confirming node-snapshot sync still preserves a stored reverse tag.
Give the issue and @claude-mention assistants the repository map, verified runtime facts, and an explicit INVESTIGATE step so every answer is grounded in the checked-out source instead of guesses. Raise max-turns (issues 45->90, mentions 40->70) and expand the mention system prompt to match.
The panel's copy/QR share links are built client-side and fell back to window.location.hostname, so reaching the panel over an SSH tunnel (127.0.0.1/localhost) leaked localhost into the links - unlike the backend subscription path, which falls back to the configured Sub/Web Domain (issue #4829).
Expose webDomain/subDomain via /defaultSettings and add preferPublicHost: when the browser host is loopback, prefer the configured Sub Domain (then Web Domain) for share/QR links. An explicit node override or per-inbound listen still wins; a routable browser host is kept as-is.
Closes#4829
A setting row whose value column is empty or NULL (seen on some migrated databases) was parsed directly, so getInt/getBool and the GetAllSetting reflection path crashed with 'strconv.Atoi: parsing "": invalid syntax'. This made the Inbounds page (/defaultSettings -> GetPageSize) and the Settings page fail to load.
Treat an empty stored value the same as a missing row and fall back to the built-in default at the int/bool parse sites. String getters are unchanged, so legitimately-empty string settings stay empty.
Closes#4830
resolveInboundAddress stopped using the inbound's bind Listen in 3.2.5/3.2.6, so a per-inbound Address/IP no longer appeared in generated subscription/share links - they always used the host the subscriber reached the panel on. The frontend QR path still honored Listen, so the panel and the subscription disagreed (issue #4798).
Restore advertising Listen when it is a routable host (real IP or hostname), reusing isRoutableHost and excluding unix-domain sockets. Loopback/wildcard binds still fall back to the subscriber host, keeping the earlier loopback-leak fix intact. Precedence is now node address > routable Listen > subscriber host; External Proxy still overrides everything.
Closes#4798
check_status() only recognized a systemd service or Alpine's
/etc/init.d/x-ui, neither of which exists in a container where the panel
runs as the foreground main process (PID 1 via "exec /app/x-ui"). Every
CLI command therefore failed with "Please install the panel first", and
restart/restart-xray relied on rc-service/systemctl that aren't present.
Detect the container (/.dockerenv or XUI_IN_DOCKER) and, when inside one:
- resolve the panel binary under /app instead of /usr/local/x-ui
- derive status from the running process instead of a service file
- restart via SIGHUP and restart-xray via SIGUSR1 to the panel process
- show Docker-appropriate guidance for start/stop/enable/disable
The Dockerfile sets XUI_IN_DOCKER/XUI_MAIN_FOLDER so detection is
explicit even though /.dockerenv alone suffices.
Closes#4817
Hysteria2 clients backed by Xray-core hex-decode the pinSHA256 URI param and crash on the base64 value the panel stores for pinnedPeerCertSha256 (xray-core native TLS format). Normalize each pin to bare lowercase hex when building the Hysteria link, accepting base64, bare hex, and colon-separated openssl fingerprints; values that are neither are passed through untouched. Applied in both the backend subscription generator and the frontend link builder. The pcs share-link and JSON-sub paths keep base64 for their consumers. Fixes#4818.
The inbounds page and Nodes page checked each client's email against a
single deduped union of every node's online clients, so a client connected
to one node showed as online on every inbound across every node. The local
online set was also derived from the email-keyed client_traffics.last_online
column, which remote-node syncs bump too, leaking remote-only clients onto
local inbounds.
Track online clients per node: the local panel's own xray clients under key
0 (derived from live traffic-poll deltas via RefreshLocalOnline, kept in
memory and independent of the shared last_online column) and each remote
node under its id. Add GetOnlineClientsByNode plus a /clients/onlinesByNode
endpoint and onlineByNode WS field; node.go and the inbounds rollup now scope
online by node. The flat GetOnlineClients union is kept for client-centric and
total-count views (Clients page, dashboard, telegram).
Closes#4809