Turn the outbound sockopt dialerProxy free-text input into a searchable Select populated with the other outbound tags, so users can build a proxy chain (route one outbound through another) without typing tags by hand. The list excludes the current outbound, so self-reference cycles cannot be selected. A tooltip and placeholder explain the chaining concept. Adds dialerProxyPlaceholder and dialerProxyHint to all 13 locales.
Closes#4446
Honor XUI_ENABLE_FAIL2BAN before running fail2ban-dependent IP-limit work. This avoids spawning fail2ban-client on disabled Docker installs while preserving the default enabled behavior when the env var is unset.
Co-authored-by: Mayurifag <Mayurifag@users.noreply.github.com>
Adds a per-node TLS verification mode to the Add/Edit Node dialog so the panel can reach nodes that serve HTTPS with a self-signed certificate:
- verify (default): normal CA validation.
- skip: InsecureSkipVerify, with a clear UI warning that it drops MITM protection.
- pin: validates the leaf certificate's SHA-256 (base64 or hex) via VerifyConnection while bypassing the default chain/name check — keeps MITM protection for self-signed certs, the secure alternative to skip.
New Node model fields tlsVerifyMode + pinnedCertSha256 (gorm auto-migrated). Probe() selects the HTTP client per node via nodeHTTPClientFor, keeping the SSRF-guarded dialer. A new POST /panel/api/nodes/certFingerprint endpoint (FetchCertFingerprint) lets the UI fetch and pin the node's current certificate in one click. Endpoint documented in api-docs/openapi; i18n added across all locales. Verified end-to-end in Docker (verify rejects, skip bypasses, fetch matches, pin accepts correct / rejects wrong).
UDS listen already worked for proxying (the listen string is passed to xray verbatim and port 0 is accepted), and the Go sub/link layer already ignores the bind listen. The only gap was the frontend resolveAddr, which would put a socket-path listen into share/sub links (e.g. vless://uuid@/run/xray/x.sock:0). resolveAddr now treats a path-style listen (starting with / or @) as having no client-reachable address and falls back to hostOverride/hostname. Adds a test and a Listen-field help hint across all locales.
Menu 20 only exported CF_Key/CF_Email, so a restricted Cloudflare API Token was misread as a Global Key and acme.sh failed with 'invalid domain'. Add a token-or-global-key prompt (default token): an API Token exports CF_Token, the Global Key keeps the previous CF_Key + CF_Email behavior. Also stop echoing the key/token value to the debug log.
Since v3.1.0 every fallback row had to reference a panel inbound via childId, so rows with only a free-form dest (e.g. 8080 or 127.0.0.1:8080 to an external Nginx) were silently dropped at three layers: the frontend save filter, the backend SetByMaster guard, and BuildFallbacksJSON. A row is now valid when it has a child OR an explicit dest; self-references normalize to childId 0, and BuildFallbacksJSON prefers an explicit dest (also fixing rows whose child was deleted). UI gains allowClear on the child picker; help text updated across all locales. Verified end-to-end in Docker: a free-form dest fallback now persists and is injected into the live xray config. Refs #4554, #4639.
Align both raw (TCP) transport forms with the Xray docs: request {version, method, path, headers} + response {version, status, reason, headers}. The outbound form was missing the request.path input, so panel-created outbounds were stuck on GET / and could not match a custom inbound path; add it with the same comma-separated array handling as the inbound. Also drop a stale inbound comment that claimed xray-core ignores the inbound request object, which contradicts both the code and the docs (request and response must match on both sides).
Go's flag package parses '-resetTwoFactor false' as '-resetTwoFactor=true' with a dangling positional 'false', so two-factor auth was always wiped on username/password reset regardless of the prompt answer. Omit the flag in the preserve branch (default is false) and use '-resetTwoFactor=true' in the disable branch.
Unix Domain Socket inbounds (listen path starting with /) use port 0, which xray-core ignores. Validation was hard-locked to a minimum of 1 in three places: the shared Zod PortSchema, the AntD InputNumber, and the Go Inbound model tag. Adds an InboundPortSchema (min 0) for the inbound form/API schemas, makes the port InputNumber min UDS-aware, and relaxes the Inbound model validate tag to gte=0. PortSchema and the Node model stay min 1.
RegWarp now stores config.client_id from the Cloudflare registration, and WarpModal sources the reserved bytes from the live config response (falling back to stored creds). Previously reservedFor read an always-missing client_id, producing an empty reserved array.
Add a self-contained 'PostgreSQL Management' submenu (main-menu option 27) so the panel can be set up and migrated without re-running the remote install script:
- Install PostgreSQL locally (server + client tools + dedicated xui user/db), ported from install.sh so x-ui.sh stays standalone
- Migrate SQLite to PostgreSQL via 'x-ui migrate-db', then write XUI_DB_TYPE/XUI_DB_DSN to the service env file and restart the panel; client tools are ensured first so in-panel backup/restore works for local and external databases
- Service control: status (clusters + port 5432), start, stop, restart, enable autostart, view log, with auto-detected cluster version
A client shared across multiple nodes has a single email-keyed client_traffics row, but each node reports its cumulative up/down. setRemoteTrafficLocked overwrote the row with one node's cumulative, so non-owning nodes hit the create branch and OnConflict-DoNothing, silently dropping their traffic and under-counting the client.
Make the shared row a pure accumulator (like the local path): a new node_client_traffics(node_id, email) baseline table stores each node's last cumulative; the node path converts cumulative to a per-node delta (clamped to the post-reset value on a negative delta) and does up = up + delta. First observation seeds the baseline and adds 0 so upgrades and newly-shared clients are not double-counted. Create-vs-accumulate now keys off global email existence. Baselines are cleaned in DelClientStat, the node sweeps, and NodeService.Delete.
Hysteria links now carry the pinned peer cert under the hysteria2-standard pinSHA256 key instead of pcs (frontend genHysteriaLink + outbound importer round-trip), and the Go subscription generator emits ech from echConfigList. Also drops the dead allowInsecure guard in genHysteriaLink, which read a field that does not exist on TlsClientSettings.
The Subscription-Userinfo header read total/expiry from client_traffics, but in a multi-node setup the master's node sync overwrites those with the node snapshot's zeros, so the header reported total=0; expire=0 even though the panel UI (which reads the clients table) showed the configured limits. AggregateTrafficByEmails now falls back to the clients table for total/expiry when the traffic row is zero, keeping up/down/lastOnline from client_traffics.
The UserPasswordHash seeder bcrypt-hashed user.Password unconditionally, assuming plaintext. If it ran on an already-bcrypt value (DB restore, SQLite<->Postgres switch, history_of_seeders inconsistency on upgrade) it double-hashed the password, locking the admin out with both old and new passwords rejected. Skip any password that is already a bcrypt hash.
Hysteria doesn't use uTLS, but the outbound TLS form's uTLS dropdown only listed concrete fingerprints (chrome, firefox, ...) with no explicit empty entry. Add a None option, matching the inbound TLS form, so the fingerprint can be left empty.
parseHysteria2Link hardcoded alpn to h3 and never read fp, ech, or the fm (finalmask) param, so importing a Hysteria2 client URL as an outbound dropped the configured ALPN, fingerprint, and salamander UDP mask. Parse alpn (falling back to h3 only when absent), fp, ech, and the pcs pinned-cert key, and restore the UDP mask via applyFinalMaskParam.
xray-core reads the bind-interface sockopt as json:"interface", but the schema and forms used interfaceName. Go's JSON unmarshal is case-insensitive, yet interfacename != interface, so the value never reached xray and interface binding silently did nothing. Rename the field across the schema, the inbound/outbound forms, and the golden fixture to match xray-core and the official docs.
genRemark can return an empty string (remark-less inbound, or a remark model that depends on the email the Clash path drops), which was set verbatim as the proxy name. mihomo rejects the whole config on a duplicate name, so two such proxies made the Clash Verge profile vanish on refresh; a single one was dropped from the PROXY group, collapsing it to DIRECT so Rule mode stopped proxying while Global still worked. Guarantee every proxy carries a non-empty, unique name before assembling the group.
The trafficDiff InputNumber and form schema lacked an upper bound, so values above 100 were accepted in the UI but rejected by the backend (gte=0,lte=100), failing the entire settings save with a misleading 'request body failed validation' error. Add max=100 to the input and .max(100) to the schema.
The link-to-JSON importer dropped two VLESS Reality fields:
- pqv (post-quantum ML-DSA-65 verify key) was never parsed; map it back
to realitySettings.mldsa65Verify, matching the inbound link generator.
- encryption was force-reset to 'none' in the form adapter regardless of
the parsed value, discarding post-quantum encryption strings.
Add regression tests for both paths.
The image bundles fail2ban (enabled by default) to enforce per-client IP
limits via iptables, but docker-compose.yml granted no capabilities. The
job logs the ban and fail2ban reports it as banned, yet the iptables
action fails with "Permission denied (you must be root)" and no rule is
inserted, so the client is never actually blocked. Add cap_add
NET_ADMIN/NET_RAW to the service and document the docker run flags.
PR #4699 restored the "keep newest live IP, ban the oldest" policy in
check_client_ip_job.go but left the integration test asserting the old
"protect original, ban newcomer" behavior, so it failed. Update the test
to expect the oldest live IP banned and the newest kept, and fix the now
misleading name/comment on the partitionLiveIps concurrency unit test.
Rename the DNS rule wire key qtype to qType (reading the legacy qtype on parse for back-compat), add the new rCode response-code field for the return action (omitted when zero), and rename the reject action to return. Align the DNS rule action set across the form dropdown, schema, and adapter to the core's valid values (direct/drop/return/hijack), dropping the never-valid rejectIPv4/rejectIPv6 entries.
Consolidate the eight legacy mKCP/header UDP mask types into a single mkcp-legacy type ({header, value}), simplify xicmp to {dgram, ips}, and add the new realm UDP mask type, matching the updated Xray-core wire format. Update the FinalMask schema enum, the transport form, the mKCP seeding default, and the backend KCP share-link translation. Refresh golden fixtures/snapshots and add backend coverage for the mapping.
The routing-rule tag picker reads inboundTags from the xray config query
(['xray','config']), but refresh() only invalidated the inbounds/clients
buckets. So after adding, editing or deleting an inbound the tag list stayed
stale until a hard refresh wiped the react-query cache. Invalidate the xray
config query too, alongside the existing inbounds-options fix.
A non-empty, non-any Address (listen) leaked into the tag as
in-<listen>:<port>-<transport> (e.g. in-127.0.0.1:443-tcp). The tag is
now always in-<port>-<transport>, with the node prefix and numeric dedup
suffix still handling uniqueness across nodes and same-port/different-listen
inbounds. Mirrored in the Go authority and the TS form preview, kept in
parity by tests.
Existing colon-form tags are now treated as custom, so editing such an
inbound preserves its tag rather than rewriting it; new inbounds (or a
cleared tag field) get the clean form.
setImmediate is a Node global not declared in the frontend's DOM tsconfig,
so tsc --noEmit failed with 'Cannot find name setImmediate'. setTimeout is
universally typed and still flushes React's pending setImmediate: looping
the awaits keeps afterEach unresolved across several event-loop iterations,
so the queued check-phase callback fires while window still exists.
Bump several Go module versions in go.mod and regenerate go.sum. Updated dependencies include github.com/go-playground/validator/v10 (v10.30.2 -> v10.30.3), github.com/shirou/gopsutil/v4 (v4.26.4 -> v4.26.5), github.com/ebitengine/purego (v0.10.0 -> v0.10.1), github.com/rogpeppe/go-internal (v1.14.1 -> v1.15.0), golang.org/x/exp (updated pseudo-version), and google.golang.org/genproto/googleapis/rpc (updated pseudo-version). These are routine patch/minor updates to pick up fixes and checksum changes.
React 19 defers passive-effect flushes onto a setImmediate callback that
reads window.event. When one was still queued as vitest tore down the
jsdom environment, it fired after window was deleted and surfaced as an
unhandled 'window is not defined' error, failing the run with exit 1
despite all tests passing. Drain the macrotask queue in afterEach so any
pending callback runs while window still exists.
The Online column already surfaced last-online on the offline tag; extend the same tooltip to the depleted (ended) tag so a depleted client's last activity is visible without enabling it.
Reject creating or editing a client with a subId already owned by a different client, mirroring the email-uniqueness checks against client_records in Create and Update (BulkCreate inherits via Create). The old multi-inbound model duplicated a client across inbounds sharing one subId, so this check was dropped; the first-class multi-client model makes per-client subId uniqueness correct again. Existing duplicates are left untouched; only new/edited duplicates are blocked.
The client column under-counted clients attached to an inbound whose shared client_traffics row is keyed to a different inbound: rollupClients filtered settings.clients down to emails that had a stat row on that inbound. Count from settings.clients membership instead. Also surface all/active/disable/depleted/online with the Clients-page color scheme and widen the column.
The summary card derived active/bucket counts from the live client_stats snapshot, which only refreshed on the next traffic broadcast (up to 5s). A removal therefore left the counts stale while only total tracked the refetched server summary. Clear the snapshot in invalidateAll so the card falls back to the authoritative server summary immediately; the next stats event repopulates it for live tracking.
List pages wrapped content in <Spin spinning={!fetched}> where 'fetched' only flipped true once data arrived. With staleTime: Infinity + retry: 1, a transient network error on first load left the query in a permanent error state and the spinner stuck forever.
Now 'fetched' also settles on query.isError, and a failed load shows a Result error card with a Refresh button that self-heals when the backend returns, mirroring the existing XrayPage pattern. Applied to clients, inbounds, groups, nodes, and the dashboard.
Fixes#4723
Adds an 'Attach Existing Clients' row action on multi-user inbounds (shown even when the inbound is empty). It opens a modal listing the whole client pool with search and group filter, all attachable clients pre-selected, and attaches the selection to that inbound via the existing bulkAttach endpoint. Clients already on the inbound are shown disabled and skipped. Translations added for all 13 locales.
Adds the ability to update node panels to the latest release from the Nodes
page: select online, enabled nodes (checkboxes) and trigger their official
self-updater, or use the per-row Update action. A node whose reported panel
version trails the latest GitHub release is flagged with an 'update available'
tag (compared via lib/panel-version, mirroring the Go isNewerVersion).
Backend: Remote.UpdatePanel calls the node's existing
POST /panel/api/server/updatePanel; NodeService.UpdatePanels fans out over the
selected ids, skipping disabled/offline nodes with a per-node reason; exposed
as POST /panel/api/nodes/updatePanel (documented in endpoints.ts + openapi.json).
The bulk request sends a JSON body, so it sets Content-Type: application/json
explicitly — axios defaults POST to form-urlencoded, which made ShouldBindJSON
fail with 'invalid character i'.
Also reuses the clients-page online cue on the Nodes page: a pulsing green dot
plus green label for an online node. The .online-dot style moved to the shared
styles/utils.css so both pages load it.
Translations for all new node keys added across every language file.
Two small UX cues on the clients table online column:
- a pulsing green dot next to the Online tag so an active client reads as
live at a glance (honors prefers-reduced-motion).
- hovering the Offline tag shows the client's last-online timestamp from
record.traffic.lastOnline, formatted with the panel's calendar setting
(or "-" when the client has never connected).
The clients page summary counters (Online / Depleted / Depleting / Disabled
/ Active) came only from the paged-list response (staleTime: Infinity), so
they stayed frozen until a manual refresh or a mutation-triggered refetch —
the per-row columns updated over WebSocket but the summary card did not.
The client_stats WS event already broadcasts every client's traffic
(enable/up/down/total/expiryTime) every few seconds, so recompute the summary
client-side from it: computeClientsSummary mirrors the server's
buildClientsSummary, the latest event is stored in allClientStats, and the
summary is a useMemo over that plus the live onlines set. Falls back to the
server summary until the first event lands and keeps the server's
authoritative total. No extra polling, consistent with the existing
no-REST-fallback traffic design.
The subscription page leaked an inbound's server-side Listen IP into the
client-facing URLs when a bind address was set:
- Per-config links: resolveInboundAddress returned the bind Listen IP
(loopback/private/public alike) instead of the host the subscriber
reached the panel on. It now returns the node address for node-managed
inbounds, otherwise the subscriber host; the bind Listen is ignored
(External Proxy remains the way to advertise a specific endpoint).
- Subscription Copy URL (SUB/JSON/CLASH): BuildURLs composed the base
differently from the panel's Client Information page and never
normalized the request host, so a loopback/bind request leaked the raw
IP. The composition is extracted into the shared
SettingService.BuildSubURIBase, used by both the panel and the sub page
so they render identically, and fed the already-normalized subscriber
host.
Auto-generated inbound tags (in-<port>-<l4>, n<id>- prefixed for node inbounds) now re-derive when port/listen/transport change on update instead of keeping the stale round-tripped value. The resolved tag is mirrored onto the API response, and NodeID is pinned to the stored row so a node inbound never loses its n<id>- prefix on edit. The edit form recomputes the tag live via a Go-parity helper so the JSON preview matches what gets saved.
Make node/central tag matching prefix-agnostic in all three places (traffic attribution, remote-id resolution, and the orphan sweep) so an n<id>- prefix present on only one side can no longer spawn duplicate inbounds or drop traffic on sync.
Force LF on shell scripts via .gitattributes (CRLF broke the Docker build shebang when the repo is checked out on Windows) and add a .dockerignore to keep node_modules/.git out of the build context.
Adds Go and frontend tests covering tag re-derivation, prefix-agnostic matching, and node-snapshot prefix mismatch.
UserLoginNotify ran SendMsgToTgbotAdmins synchronously on the login request goroutine. When Telegram was unreachable, the send retried up to 3x with a 30s timeout each, blocking the login handler for ~90s+ and effectively locking users out (issue #4585).
Dispatch the send in a goroutine after the cheap bot-running/login-notify-enabled guards so login always returns promptly; the existing per-send 30s context timeout and bounded retries keep the background goroutine from leaking.
Adding a TUN inbound failed with "request body failed validation" because the Inbound.Protocol oneof allowlist omitted "tun". Add it so the validator matches the protocol the frontend already offers.
Closes#4736