Commit Graph

226 Commits

Author SHA1 Message Date
Sanaei 5e1cb7693b Repo-wide self-correcting audit: 54 verified bug fixes (#5970)
* fix(email): resolve a name-addr smtpFrom into bare envelope address and display name

The save-time validator accepts any RFC 5322 address form, so a value
like '3x-ui Panel <panel(at)example.com>' passes validation, but Send and
TestConnection fed that raw string to MAIL FROM, which strict servers
reject with 501, and buildMessage mangled it into a quoted local part.
Parse the configured sender at the point of use: the envelope gets the
bare address and, when no explicit sender name is set, the display name
embedded in the setting is used for the From header.

* fix(email): report a missing sender address from the SMTP connection test

TestConnection skipped the empty-from guard that Send enforces, so with
no sender and no username configured the test issued the null reverse-path
and could report success against a lenient relay while every real
notification send kept failing with the missing-sender error. Guard the
test path the same way and surface a dedicated translated message.

* fix(sub): fall back to the raw subscription when an auto-detected format has no content

With format auto-detection enabled, a client whose User-Agent matched the
Clash or JSON regex was routed straight to that format handler. For a
subscription whose entries convert to neither format (an MTProto-only
subscription, for example) the handler returns an empty document and the
request ended as 404, breaking a URL that served the raw list before the
toggle. The auto-detect branches now serve the detected format only when
it produces content and otherwise continue to the raw response; the
explicit format endpoints keep answering 404 for empty documents.

* fix(node): match prefixed central tags when filtering a selected-mode node snapshot

FilterNodeSnapshot compared a node snapshot's inbound tags against the
raw selected-tag list with an exact match, while its two siblings
(SnapshotHasUnadoptedInbounds and the reconcile tagToCentral map) expand
each selected tag to both its bare node-side form and its n<id>- prefixed
central form. A panel-created node inbound is recorded in the selected
list under the central prefixed tag but reported by the node under the
bare tag, so the exact match dropped it from every snapshot and the
orphan sweep then deleted its central row one tick after creation. Expand
the allowed set with the same prefix flip the siblings use.

* fix(client): refuse a bulk quota reduction that would fall to or below zero

BulkAdjust clamped a client's new traffic limit with max(total+addBytes, 0).
Because 0 is the unlimited sentinel, reducing a client's quota by more than
it had left silently granted that client unlimited traffic. The sibling
expiry branch already refuses an over-reduction; mirror it for quota so the
adjustment is skipped with a clear reason instead of crossing the sentinel.

* fix(client): persist a bulk adjustment's applied field even when the sibling field is skipped

In a mixed BulkAdjust (both a days delta and a bytes delta), a per-field
planning skip such as "unlimited expiry" or "unlimited traffic" was recorded
in the same map that gated the client_traffics write. The applied field was
already written to the inbound JSON and the clients table, but the enforcement
row was left untouched, so the depletion job cut the client on the old limit
while the panel showed the new one. Gate the traffic-row write on an actual
inbound-processing failure rather than on any planning-phase skip note.

* fix(inbound): always create in AddInbound instead of overwriting a row whose id was posted

The add controller binds the inbound model's id form field and never clears
it, and AddInbound persisted with GORM Save, which updates in place when the
primary key is non-zero. A client that reused an existing id (for instance by
duplicating an inbound fetched from /get and changing the port) silently
overwrote that stored row instead of creating a new inbound. Zero the id at
the top of AddInbound, matching how it already zeroes the client-stat ids.

* fix(inbound): accept WireGuard clients when creating an inbound

AddInbound's per-client validation switch had cases for every protocol
except WireGuard, so a WireGuard client fell through to the default branch
that requires a non-empty id. WireGuard clients are keyed by their public
key and carry no id, so importing a WireGuard inbound or re-adding one to a
reconciling node was rejected with "empty client ID". Add a wireguard case
that validates the client key, mirroring addInboundClient.

* fix(client): stop holding the inbound-lock registry mutex while waiting on one inbound

lockInbound acquired the global registry mutex and then blocked on the
per-inbound mutex without releasing the registry first. A slow client
operation holding one inbound's mutex (for example a bulk delete pushing to
an unreachable node) made the next waiter park on that inbound while still
holding the registry mutex, which in turn blocked lockInbound for every
other inbound — freezing client mutations panel-wide. Release the registry
mutex before taking the per-inbound lock.

* fix(client): honor keepTraffic when deleting a client that is attached to inbounds

Delete, DeleteByEmail and BulkDelete all pass keepTraffic to their final
cleanup transaction, but each called the per-inbound delete helper with a
hardcoded false. That helper purges the client's traffic, IP and stat rows
before the gated cleanup runs, so keepTraffic=true still destroyed all
traffic history for any client actually attached to an inbound (the pinned
test only covered a record with no inbound mappings). Thread the caller's
keepTraffic through to the per-inbound helper at all three call sites.

* fix(inbound): defer a local MTProto inbound edit's sidecar push until after commit

UpdateInbound applied a local MTProto inbound change by calling the runtime
UpdateInbound (which stops/starts the mtg sidecar or talks to it) from inside
runSerializedTx. That runs process and network I/O on the single traffic-writer
goroutine while a DB transaction is open, so a slow sidecar stalls traffic
accounting and every concurrent client mutation, and a later step failing the
transaction leaves the sidecar ahead of the rolled-back row. Move the push into
the post-commit hook, matching the xray branch. Adds a SetLocalRuntimeOverride
test seam mirroring the existing node override so the deferral is regression
tested.

* fix(client): delete external-link rows when bulk-deleting clients

The single-client Delete path removes a client's client_external_links rows,
but BulkDelete (and the DelDepleted reaper that routes through it) deleted the
record, mappings and traffic while leaving the external-link rows keyed by the
now-dead client id, so they accumulated as orphans. Delete them in the same
cleanup transaction, keyed by client id like the single path.

* fix(inbound): request an xray restart when toggling a routed MTProto inbound

AddInbound, DelInbound and UpdateInbound all flag needRestart when an inbound
routes MTProto through xray, so the egress SOCKS bridge is regenerated. Only
SetInboundEnable's local path omitted it, so toggling a routed MTProto inbound
off then on left the bridge out of the running config while the sidecar dialed
its loopback port, blackholing that inbound until an unrelated restart. Flag the
restart on the local enable path too.

* fix(client): apply enable-by-email to every inbound a client is attached to

ToggleClientEnableByEmail (Telegram bot) and SetClientEnableByEmail (LDAP sync)
resolved a single inbound via the legacy client_traffics pointer and flipped
enable only there. A client attached to several inbounds kept connecting through
the siblings' running Xray after being disabled, and the next edit could
re-enable it everywhere from a stale sibling. Route both through the
applyClientFieldByEmail fan-out (the #5039 fix path) so the whole multi-inbound
identity is toggled at once, dropping the circular Set/Toggle dependency.

* fix(traffic): commit a traffic tick even when a best-effort maintenance helper fails

addTrafficLocked stages the inbound and client deltas, then runs three helpers
(auto-renew, disable depleted clients, disable depleted inbounds) that are meant
to log and continue. All three reused the function-scope err that the deferred
commit/rollback inspects, so the last helper's error decided the whole tick: a
failure in disableInvalidInbounds rolled back the already-staged traffic while
AddTraffic reported success, and because xray had already advanced its counter
baseline that traffic was lost for good. Give each best-effort helper its own
error variable so only a genuine staging failure rolls the tick back.

* fix(traffic): re-enable clients and serialize the write in Reset All Client Traffic

ClientService.ResetAllTraffics zeroed up/down but, unlike every sibling reset
path, never restored enable=true, so clients that had been auto-disabled for
exceeding their quota stayed cut with zero usage after a reset. It also wrote
client_traffics directly on the shared DB handle instead of through the serial
traffic writer, reintroducing the cross-transaction lock-order deadlock the
writer exists to prevent. Restore enable and run the reset inside
submitTrafficWrite within one transaction.

* fix(traffic): keep node reset propagation out of the serial traffic writer

ResetAllTraffics and ResetInboundTraffic performed their remote-node reset HTTP
calls inside submitTrafficWrite. Each call can block up to the remote timeout,
and Reset All Traffics loops every node serially, so the single traffic-writer
goroutine was held for seconds — long enough that the concurrent 5s traffic poll
timed out submitting its own write and dropped the deltas it had already drained
from xray. Do the DB reset inside the writer, then propagate to the nodes after
it returns, matching how the mtproto quota reset is already sequenced.

* fix(sub): stop the subscription from 500ing on valid-but-unusual stream settings

The raw share-link generators used unchecked type assertions and unguarded
array indexing: an empty Reality shortIds/serverNames array (random.Num(0)
panics), a tcp-http header with no request block or an empty request.path, a
grpc block missing its keys, empty stream settings, and a non-string Host
header all panicked mid-generation. Because getSubs loops every client's link
with no recover, one such client 500s the entire subscription for everyone. The
sibling JSON, Clash and frontend generators already guard these; make the raw
generators match with comma-ok assertions and length checks.

* fix(sub): tolerate a hysteria inbound without hysteriaSettings in the JSON subscription

genHy asserted stream["hysteriaSettings"].(map[string]any) without the comma-ok
form, so a hysteria inbound whose StreamSettings omit the hysteriaSettings key
(a valid, representable shape the raw generator renders fine) panicked and 500ed
the entire JSON subscription. Use comma-ok; the downstream reads already guard
each key, so a nil map degrades gracefully.

* fix(sub): emit the pinned peer cert sha256 in Clash subscriptions

The Clash stream builder computed tlsSettings["pin-sha256"] from the inbound's
pinnedPeerCertSha256, but applySecurity's tls case never copied it onto the
proxy, so it was written with no reader and silently dropped. Clash subscribers
lost certificate pinning while JSON subscribers kept it. Surface pin-sha256 on
the proxy in the tls case, matching the JSON emitter.

* fix(link): parse the snake_case and extra-blob xhttp fields when importing a share link

The panel's share-link emitters (Go and TS) carry advanced xhttp knobs as a
snake_case x_padding_bytes plus an extra=<json> payload, but the Go parser's
xhttp branch read only top-level camelCase params, so importing an xhttp link
via the outbound-subscription feature dropped xPaddingBytes, scMaxEachPostBytes
and the rest, silently reverting them to the stream defaults and producing a
non-working outbound. Mirror the TS parser: read the snake_case alias, merge the
extra JSON blob, then let explicit camelCase params win.

* fix(frontend): decode URL-safe base64 when parsing an imported share link

Base64.decode called window.atob directly, which rejects the base64url
alphabet (- and _) and unpadded input. But the panel's own share-link emitter
uses Base64.encode(x, true) (URL-safe, unpadded), and real SIP002 links do too,
so importing a Shadowsocks link whose method:password encodes with a - or _ threw,
fell back to the raw undecoded string, and produced a wrong method and garbage
password (the vmess parser shared the same limitation). Normalize base64url and
re-pad before atob so decode round-trips every emitted link.

* fix(link): honor the vmess ws path and hysteria2 vcn params on import

Two Go/TS parser parity gaps in the outbound share-link import path: parseVmess
only applied a ws link's path when the inner JSON also carried a host key, so a
generator that omits host dropped the path back to the default; and parseHysteria2
hardcoded verifyPeerCertByName to empty, ignoring the vcn param the panel emits,
so a hysteria2 outbound with a decoy SNI and a distinct cert name failed TLS
verification after import. The TS parser handles both; make the Go parser match.

* fix(ui): stop the sniffing form island from clobbering unrendered fields

antd's Form.useWatch only reports registered fields, so while the
sniffing toggle was off the island emitted { enabled: false } upward and
replaced the full Sniffing object in form state. Saving a VLESS reverse
outbound then crashed in sniffingToWire on the missing ipsExcluded
array; the loopback outbound and the inbound sniffing tab shared the
same hole. Watch the store with preserve: true so unrendered fields
keep their values, and seed a missing value from the schema defaults
instead of an empty cast.

* fix(sub): drop empty remark segments instead of leaving a stray separator

expandSegment dropped a "|" segment only when its tokens rendered the unlimited
mark, so a segment whose only token resolved to the empty string (a client with
no comment, an unlimited client's expiry date) was kept as bare decoration,
leaving a trailing "|" or a dangling emoji on every share link's remark. Drop a
token-bearing segment whenever none of its tokens produce a real value, while
still keeping pure-literal segments.

* fix(xray): keep source- and domains-scoped routing rules when an inbound is deleted

removeInboundTagFromRules drops a routing rule whose inboundTag list becomes
empty only if the rule has no other matcher, but routingMatcherKeys omitted
xray-core's canonical source and domains keys. A rule scoped by source or domains
(common in hand-authored or imported configs) therefore lost its whole body —
including a security-relevant block — when its single listed inbound was deleted,
instead of just having the tag trimmed. Recognize source and domains as live
matchers.

* fix(xray): guard RemoveUser against an uninitialized handler client

Every XrayAPI handler method returns an error when HandlerServiceClient is nil,
except RemoveUser, which dereferenced it directly. A depletion sweep runs Init
with the port ignored and, during a restart window where the fresh process's
api port is still 0, Init fails and leaves the client nil — so RemoveUser
panicked (recovered by the traffic writer, but re-thrown every poll) instead of
returning an error. Add the same nil guard the siblings have.

* fix(xray): do not revive a manually stopped Xray on a background restart

RestartXray cleared isManuallyStopped unconditionally at its top, so the @30s
pending-config cron (and warp/ldap/outbound reconcile jobs) that call
RestartXray(false) resurrected an Xray the admin had deliberately stopped —
unlike the crash-detector, which honors the manual-stop flag. Skip a non-forced
restart while the stop flag is set; only an explicit forced restart clears it.

* fix(xray): retry a failed pending-restart instead of dropping the config change

The 30s cron consumed the need-restart flag with IsNeedRestartAndSetFalse before
calling RestartXray and only logged a failure. If RestartXray failed early (a
transient GetXrayConfig DB error) the old process kept running the old config,
the crash detector saw a running process and never retried, and the flag stayed
cleared — so an admin's saved change silently never reached the core. Move the
consume/restart/retry into ApplyPendingRestart, which re-arms the flag on
failure so the next tick retries.

* fix(xray): synchronize the process version and apiPort fields

Start writes p.version and p.apiPort (via refreshVersion/refreshAPIPort) after
flipping the process to running, while GetXrayVersion and GetAPIPort read them
lock-free from the status and traffic poll goroutines. The struct mutex
deliberately excluded these fields, so a restart racing a poll was a real data
race — a torn read of the version string header can crash. Extend the mutex to
cover version and apiPort, doing the blocking version probe before taking the
lock.

* fix(settings): detect a wildcard listen collision between the web and sub ports

The web/sub same-port check compared the two listen addresses as raw strings, so
binding both on all interfaces with different spellings (webListen 0.0.0.0 vs an
empty subListen) slipped past validation and only failed at startup with an
opaque bind error. Treat any wildcard listen ('', 0.0.0.0, ::) as overlapping so
the clash is reported up front, while still allowing two distinct specific
addresses to share a port.

* fix(db): mark the IP-limit cleanup seeder done on a fresh install

ResetIpLimitNoFail2ban is a one-time migration that, on a host without fail2ban,
zeroes every existing client's limitIp because the limit can't be enforced. It
was missing from the fresh-install fast-path seeder list, so on a brand-new DB it
did not run on the first boot but fired on the second — wiping any IP limits the
admin had set in between. Add it to the fast-path so a truly fresh install marks
it done up front (there is nothing to clean), leaving later admin-set limits
intact.

* fix(security): dial outbound subscriptions through the SSRF guard

The outbound-subscription fetch validated the URL host once (resolving DNS and
rejecting private targets) but then fetched with a plain HTTP client that
re-resolves the host at dial time, so a subscription domain the attacker controls
could pass validation as a public IP and rebind to 127.0.0.1 / a cloud metadata
endpoint / an internal host for the actual dial — a blind SSRF into the panel's
network. Route the direct fetch (and its redirects) through
netsafe.SSRFGuardedDialContext, which resolves, checks and dials the same IP
atomically, carrying the subscription's AllowPrivate flag on the request context;
a configured egress proxy still dials its loopback bridge unguarded.

* fix(security): bound the login-limiter attempts map

The login rate limiter keys its records on the caller-supplied username and only
evicted a record when that exact key was revisited or the login succeeded. An
unauthenticated attacker replaying one CSRF token while rotating a fresh username
per request seeded a record that was never revisited, growing the map without
bound until the panel OOMs. Cap the map: before inserting a new record, reclaim
records whose block has lapsed and whose failures aged out, and if the map is
still at the ceiling under a broad flood, drop one so memory can never grow past
the cap.

* fix(tgbot): require admin for privileged callbacks, not just the first switch

answerCallback wraps only its first callback switch in an isAdmin guard; the
second switch (server usage, inbound/online enumeration, database backup export,
ban logs, mass traffic reset, client creation) ran for every caller. Telegram
delivers a callback with the tapping user's id, so a non-admin who can see an
admin's inline keyboard — as when the bot runs in a group — could tap Backup and
receive the full database and config, or reset all traffic. Default-deny before
the second switch: a non-admin may only run the per-user client_* callbacks that
resolve their own data from their Telegram id.

* fix(eventbus): dispatch each subscriber in its own goroutine

The fan-out loop called every subscriber's handler sequentially on the
single dispatch goroutine. The email and Telegram notifiers block on
network I/O for tens of seconds (or minutes when the remote is slow), so
one slow subscriber stalled the whole loop: the 256-slot channel then
filled and Publish silently dropped later events — including high-value
xray.crash and node.down notifications unrelated to the slow handler.

Hand each delivered event to every handler in its own goroutine so a
blocking subscriber can no longer stall delivery to the others. safeCall
already recovers panics, so a detached handler cannot take down the bus.

* fix(integration): cap WARP API response body size

doWarpRequest read the response with an unbounded io.ReadAll, unlike the
sibling NordVPN client which already caps every read at maxResponseSize.
A hostile panel egress proxy or a MITM on the Cloudflare WARP endpoint
could stream an arbitrarily large body and force the panel into an
unbounded allocation. Wrap the body in an io.LimitReader(maxResponseSize)
to match the NordVPN client.

* fix(email): bound every SMTP step with a connection deadline

The "starttls"/"none" transport delivered through net/smtp.SendMail, which
dials with an untimed net.Dial and never sets a socket deadline. When an
SMTP server accepted the TCP connection but then stalled (or was a
blackhole), the caller was released by Send's 30s select, but the sender
goroutine and its socket stayed blocked until the OS TCP timeout — minutes
per notification, leaking a goroutine and a connection each time.
sendWithTLS dialed with a timeout but likewise armed no deadline on the
protocol phase, and TestConnection (called synchronously from the settings
handler, with no select guard) could hang the request indefinitely.

Replace SendMail with sendPlain, which dials with smtpConnectTimeout and
arms conn.SetDeadline(smtpDeadline) before the greeting read, preserving
SendMail's opportunistic STARTTLS upgrade. Arm the same deadline in
sendWithTLS and TestConnection so every SMTP step is bounded.

* fix(server): guard access-log parser against malformed lines

GetXrayLogs split each Xray access-log line on whitespace and then read
fixed offsets — parts[1] for the timestamp and parts[i+1] after the "from",
"accepted" and "email:" markers — without checking the line had that many
fields. A truncated or malformed line (the logged destination is
attacker-influenced) indexed past the slice and panicked; the panel handler
returned a 500 via Gin's recovery.

Extract the per-line field parsing into parseAccessLogFields and length
guard every positional lookup so a short line yields a partial entry
instead of panicking.

* fix(server): guard xray key-generator output parsing

GetNewX25519Cert, GetNewmldsa65 and GetNewmlkem768 parsed xray's stdout by
reading lines[0], lines[1] and each line's second colon-separated field
without any length check — unlike GetNewEchCert, which already guards its
line count. If the xray binary printed fewer than two lines or reformatted
its labels (a version change, or a silent failure that emitted nothing),
the fixed slice index panicked and the handler 500'd.

Extract the shared parsing into parseXrayKeyPairOutput, which length guards
the line count and each label split and returns an error instead of
panicking, then route all three generators through it.

* fix(tgbot): stop auto-deleted messages from resetting wizard state

SendMsgToTgbotDeleteAfter spawns a goroutine that, after the display delay,
deleted the transient message and then unconditionally cleared the chat's
conversation state. Every caller that ends a wizard step already clears the
state synchronously, so that call was redundant — and harmful: if within
the delay the user advanced to the next step (a callback sets a fresh
awaiting_* state), the late goroutine wiped it, and the user's next message
fell through unrecognized, silently dropping their input.

Move the delayed deletion into deleteMessageAfterDelay, which only removes
the message and no longer touches the conversation state. Guard
deleteMessageTgBot against a nil bot so the deletion path is unit-testable.

* fix(frontend): refetch a fresh CSRF token on 403 instead of reusing the stale meta tag

On a 403 to an unsafe method the client cleared its cached CSRF token and
called ensureCsrfToken to retry. But ensureCsrfToken prefers the
<meta name="csrf-token"> tag baked into the page, which the production
panel always injects, so the "refresh" re-read the same stale token and the
/csrf-token refetch was never reached — the retry re-sent the token that had
just been rejected and the save failed with an error toast.

The token lives in the session and rotates when the session is regenerated
(for example re-login in another tab), leaving the tab's baked-in meta token
stale. Fetch the current token straight from /csrf-token in the 403 branch so
the retry uses the authoritative server value. The existing tests only passed
because they strip the meta tag; the new test keeps a stale tag present.

* fix(frontend): surface backend error text from failed requests

HttpUtil.get/post read the thrown HttpError body as response.data.message,
but the backend error envelope (entity.Msg) serializes its text as msg. On
any non-2xx JSON response the real reason was therefore dropped and the
operator saw only the generic "Request failed with status N" toast.

Read response.data.msg first (keeping message and the native error text as
fallbacks). The sibling test had pinned the wrong body shape ({ message });
correct it to the real backend shape ({ success:false, msg }) so it exercises
the actual envelope.

* fix(frontend): share one WebSocket connection across bridge and hooks

websocketBridge.ts and useWebSocket.ts each declared their own
module-scoped sharedClient plus an identical getSharedClient, so the
"shared" client was not shared between them: whenever a page using
useWebSocket (Clients/Inbounds) mounted alongside the always-mounted
bridge, the panel opened two sockets to /ws. The server then pushed every
traffic/stats/nodes/inbounds snapshot to both, doubling WebSocket bandwidth
and running two independent reconnect loops, and the hook's socket was never
disconnected on unmount.

Hoist a single getSharedWebSocketClient into api/websocket.ts and route both
the bridge and the hook through it, so exactly one connection is opened.

* fix(frontend): guard the outbounds WebSocket handler against non-array payloads

onOutbounds wrote the raw WebSocket payload straight into the
outboundsTraffic cache, unlike the sibling onNodes/onInbounds handlers which
first check Array.isArray. A malformed non-array push (for example an object)
would land in the cache with staleTime Infinity; consumers that call
.find()/.map() on the outbounds list would then throw and crash the Outbounds
tab. Add the same Array.isArray guard so a bad push is ignored.

* fix(frontend): key the node table by the computed row key, not id

The desktop node table used rowKey="id", but transitive sub-nodes (the
read-only rows surfaced from downstream nodes) all carry id 0, so a topology
with two or more transitive rows gave React duplicate keys. antd's rowKey
prop overrides the row object's own computed `key` (`t-${guid}` for
transitive rows, the numeric id otherwise), so the unique key the code
already builds was ignored — causing row-state/DOM mis-association on any
re-render (heartbeat refetch, address-eye toggle). The mobile card path
already keyed by record.key.

Key the table by "key" so transitive rows get their distinct t-${guid}
identity; direct nodes keep key === id, so row selection (filtered to numeric
keys) is unchanged.

* fix(frontend): map routing row actions through the rule's real index

The routing table hides balancer-loopback rules (`_bl_*`) but keeps each
visible row's original index in `key`, then handed antd's positional row
index straight to edit/delete/toggle/move/drag — all of which mutate the
full, unfiltered routing.rules array. Once a hidden loopback rule precedes a
visible one (e.g. a balancer whose fallback is another balancer, plus any
rule added afterwards), the positional index no longer matches the array
index, so deleting or editing a rule silently hit the wrong one — including
destroying the loopback rule that keeps the balancer alive.

Add originalRuleIndex to translate a positional row index back through the
row's `key`, and route every mutating handler (openEdit, confirmDelete,
toggleRule, moveUp/moveDown, drag) through it. When no loopback rows are
hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): map outbound row actions through the outbound's real index

The outbounds table hides balancer-loopback outbounds (`_bl_*`) but keeps
each visible row's original index in `key`, then passed antd's positional
row index to edit/delete/move and to the per-row probe (onTest) and its
result lookup — all of which address the full, unfiltered outbounds array.
Once a hidden loopback outbound precedes a visible one, the positional index
diverges from the array index, so deleting or editing an outbound hit the
wrong one (its deletion-impact plan and removal targeting the wrong entry),
and the test button probed / showed results against the wrong outbound.

Add originalOutboundIndex and route the mutating handlers through it; key
the probe trigger and test-result columns by record.key. With no loopback
rows hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): tolerate a malformed happyEyeballs value in the Xray Basics tab

BasicsTab derived directHappyEyeballs by calling HappyEyeballsSchema.parse
during render, guarding only against null/non-object. A wrong-typed field
(e.g. happyEyeballs.tryDelayMs as a string) or any other shape mismatch —
reachable via the Complete Template JSON editor or an imported config — threw
straight out of render, white-screening the default Xray landing tab.

Use safeParse and fall back to null so a bad value degrades to "no override"
instead of crashing the page.

* fix(frontend): preserve routing-rule fields the form does not surface

The rule form rebuilt the rule from a fixed literal of only the fields it
edits, and RoutingTab replaces the rule wholesale on confirm. Fields the
form never exposes — localPort, localIP, process, ruleTag, webhook — are in
the rule schema and can arrive via the advanced JSON editor or Import Rules;
opening such a rule in the form and saving silently dropped them.

Carry over every key of the original rule the form does not manage before
applying the form-derived fields, so an edit only touches what it surfaces.

* fix(frontend): re-sync the sniffing island when its value changes externally

The sniffing config editor froze its seed value at mount and only watched
its own inner AntD form, never reflecting a later change to the shared RHF
`sniffing` path. Because the inbound form mounts every tab with
forceRender, the friendly Sniffing tab and the Advanced JSON editor are live
at once: editing sniffing in the JSON editor updated the RHF value but not
the frozen island, so the next interaction with the friendly tab emitted the
stale value and silently discarded the JSON edit.

Add an effect that pushes an external value change into the inner form,
guarded by the same lastEmitted marker the emit path uses so the island
never re-seeds from its own echo and no update loop forms.

* fix(frontend): don't drift a client's byte quota on a no-op save

The quota field shows the total in GB rounded to two decimals; editing a
client and saving converted that display value straight back to bytes. A
byte total not aligned to 0.01 GB — one set via the API or an import — was
therefore rewritten to the rounded value on any save that never touched the
field, losing a few MB each time.

Add resolveTotalBytes: keep the original byte total when the displayed GB
still matches it, and only re-derive from GB when the user actually changed
the field.

* fix(eventbus): deliver events on a bounded per-subscriber worker

The previous fix dispatched each event to every subscriber with a bare
`go safeCall`. That unblocked the dispatch loop, but removed the bus's
backpressure: under a login-attempt flood (which both notifier subscribers
process without rate-limiting) with email/Telegram enabled, every attempt
spawned handler goroutines that each block on network I/O for up to ~30s,
with no bound — a goroutine and outbound-connection storm. It also let a
subscriber's handler run concurrently with itself, racing the Telegram
notifier's lazily-cached hostname.

Give each subscriber its own bounded queue drained by a single worker
goroutine. Dispatch does a non-blocking send per subscriber (dropping only
that subscriber's event when its queue is full), so a slow subscriber still
can't stall the others, concurrency is bounded to one in-flight handler per
subscriber, per-subscriber event order is preserved, and Stop again waits
for in-flight handlers to finish.

* fix(frontend): map outbound mobile-card actions through the real index too

The desktop outbounds table was keyed by the outbound's real index, but the
mobile card list was left keying the probe trigger and every test-state
lookup by the positional row index. With a hidden balancer-loopback outbound
present, tapping Check on a mobile card probed the wrong outbound and the
Test-All results landed on the wrong card. Key onTest and the
testResult/isTesting reads by record.key, matching the desktop columns.

* fix(frontend): meet WCAG AA contrast on the config-block link text

The Storybook accessibility test flagged the share-link <code> block: with no
explicit color it inherited a muted grey that renders as #888888 on the
#f8f8f8 tertiary-fill background in CI's Chromium — a 3.33:1 contrast, below
the 4.5:1 AA threshold. Set the text to the theme's primary text token so the
colour is explicit and high-contrast in both light and dark themes instead of
depending on an inherited value that varies by browser.

* style(sub): simplify a negated conjunction to satisfy staticcheck QF1001

golangci-lint (staticcheck QF1001) flagged the `!(a && b)` guard in
expandSegment. Rewrite it via De Morgan's law to the equivalent
`!a || !b` form so the linter passes; behavior is unchanged.

* fix: close panics and races the audit's own fixes left nearby

Second-pass review of the 54-commit self-correcting audit. Each item below
was confirmed by reading the surrounding source (and, where practical, the
pre-fix code) before being changed; regression tests are included for every
behavioral fix.

Concurrency:
- eventbus: Bus.Subscribe called wg.Add with no synchronization against a
  concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a
  Telegram-bot settings save racing panel shutdown/restart). Stop now flips a
  mu-guarded `stopped` flag before waiting, and Subscribe checks it under the
  same lock, so Add and Wait can no longer race.

Security:
- login_limiter: evictForRoom's fallback eviction picked an arbitrary map
  key, including ones still under an active cooldown - an attacker flooding
  /login with fresh usernames could evict their own (or anyone's) blocked
  record and reset the lockout. The fallback now skips actively-blocked
  records, only falling back to an unconditional evict if the map is
  somehow entirely full of active blocks (preserves the hard memory cap).

Subscription-endpoint panics (reachable by any client hitting /sub):
- internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp
  with no path settings object) and the TLS alpn readers in three places
  used unchecked type assertions - exactly the bug class abab7cd0 patched
  elsewhere in the same switch statements, just not these call sites.
- internal/sub/json_service.go, clash_service.go: the externalProxy loops
  in the JSON and Clash generators used unchecked assertions on a
  legacy/admin-supplied field (missing "port", non-object entry, etc.).
- internal/sub/json_service.go: realityData's shortId/serverName selection
  could assert a non-string array element.

Other correctness:
- client_traffic.go: ResetAllTraffics (touched by 3eb214d0) still skipped
  clearing NodeClientTraffic node-sync baselines, unlike its sibling reset
  paths in the same file - a node's next sync would re-add pre-reset delta
  on top of the freshly-zeroed counter.
- inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were
  silently discarded; now logged so a backend-level commit failure (e.g. an
  aborted Postgres tx from a best-effort helper) doesn't masquerade as a
  successful tick.
- outbound_subscription.go: the new subscriptionFetchClient doc comment was
  wedged between fetchAndStore's existing comment and fetchAndStore itself,
  leaving fetchAndStore undocumented and the comment describing the wrong
  function.

Convention cleanup:
- Removed narrative // comments added by the audit that violate this repo's
  no-inline-comment rule (mostly narrating the specific bug/fix rather than
  a lasting contract, and mostly on new Test functions, which this repo's
  existing tests never comment) - calibrated against this exact codebase's
  own pre-existing comment style so legitimate godoc-style doc comments
  were left alone.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-17 00:33:06 +02:00
Tomi lla 129f50d92a feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)
* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-14 13:01:40 +02:00
Yuri Khachaturyan 1cfd7b49b0 fix(email): build an RFC 5322 message with a proper From address and name (#5941)
The notification/test email carried only From/To/Subject/MIME headers, and
the From header was the raw SMTP username. Two problems:

- When the SMTP login is not a bare email address (common with relays and
  submission services), the From header has no valid address and strict
  receivers reject the message — e.g. Gmail returns "550-5.7.1 ... Messages
  missing a valid address in From: header".
- There was no Date (mandatory per RFC 5322 section 3.6) and no Message-ID,
  which also raises spam score.

Add smtpFrom (sender address) and smtpFromName (display name) settings and
assemble the message with net/mail: a name-addr From ("Name" <addr>), a
Date, a Message-ID, and an RFC 2047 encoded Subject, in a deterministic
header order. From falls back to the username when smtpFrom is empty, so
existing setups keep working. Wire the settings through the model, the SMTP
send and test paths, the Email settings UI, and all 13 locale files;
regenerate the Zod/OpenAPI artifacts.

Validate smtpFrom in AllSetting.CheckValid (reject anything net/mail cannot
parse), which surfaces a bad address at configuration time and prevents CRLF
header injection; strip CR/LF in buildMessage as defense in depth. Add
buildMessage and CheckValid tests.
2026-07-14 12:55:46 +02:00
MHSanaei e211a5cc47 feat(frontend): hide redundant migration download on sqlite panels
Back Up's .db now restores directly into a PostgreSQL panel, so the
SQLite-side Download Migration row only duplicated it; the row stays on
PostgreSQL panels where it is the only PG-to-SQLite path. Restore
accepts .dump and .db everywhere, the backup modal texts describe the
accepted formats in all locales, and the orphaned migrationDownloadDesc
key is removed.
2026-07-12 20:14:42 +02:00
MHSanaei 77dffe9a85 feat(server): sniff sqlite panel restore uploads and keep the fallback on failure
The SQLite panel's Restore now detects the upload by content like the
PostgreSQL panel does: migration dumps are rebuilt with RestoreSQLite,
pg_dump archives get a clear error instead of 'Invalid db file format',
and every upload passes the panel-schema pre-flight before Xray stops.
The .backup fallback survives a failed Xray start and is named in the
error, the DB pool is reopened on every error path after CloseDB, and a
failed InitDB closes the imported file before restoring the fallback so
the rename cannot hit a Windows sharing violation.
2026-07-12 20:14:22 +02:00
MHSanaei 30b611614b feat: import SQLite migration dumps through the PostgreSQL panel restore
The SQLite panel's Download Migration produces a portable SQL text dump
advertised as seeding a PostgreSQL panel, but the PostgreSQL Restore only
accepted pg_dump custom archives, so the migration file was rejected with
'Invalid file' even though the upload picker asked for .dump. importDB now
sniffs the upload header: PGDMP archives keep the pg_restore path, while
raw SQLite databases (.db) and SQL text migration dumps are rebuilt,
integrity-checked, and copied into PostgreSQL with the same MigrateData
engine as 'x-ui migrate-db --dsn'. The restore picker accepts .dump/.db on
PostgreSQL and the backup modal texts describe the accepted formats in
every locale.
2026-07-12 18:04:38 +02:00
isultanov99 30f6bc1833 feat: Add outbound egress metadata (IP + country) (#5886)
* Add outbound egress metadata

Show egress IP and country information for outbound HTTP tests. The probe reuses the temporary SOCKS route from the existing HTTP test and fetches Cloudflare trace metadata after the reachability check succeeds.

The outbound list now adds separate Egress and Country columns, hides egress IPs until the user reveals them, and marks Cloudflare WARP results with an orange cloud pill. Mobile cards keep the same data compact by placing the country and IPv4/IPv6 values on separate lines.

Validation: npm run typecheck; npm run lint; npm run build; go test ./internal/web/service/outbound

* Use context-aware DNS lookup for egress trace

* Address outbound egress review feedback

Restore the Real Delay selector and TCP default so the egress metadata change does not remove an existing test mode.

Keep HTTP probe tests hermetic by stubbing egress trace lookups, run IPv4 and IPv6 trace fetches concurrently with a shorter diagnostic timeout, scope mobile IP reveal state per row, support keyboard activation for reveal toggles, and treat WARP+ trace values as WARP-like.
2026-07-12 15:09:52 +02:00
MHSanaei 814cda3fb4 feat(xray): update xray-core to v26.7.11 and adapt panel
Bump xtls/xray-core to 50231eaf (v26.7.11) and the three binary pins
(DockerInit.sh, release.yml x2) in lockstep.

Adapt the panel to the upstream changes:

- Shadowsocks "none"/"plain" and VMess "none"/"zero" were removed from
  the core. A migration rewrites stored none/plain SS methods to a
  supported cipher and none/zero VMess security to "auto" (on both the
  clients column and inbound settings JSON); the SS build-time heal does
  the same so a row injected after boot cannot brick startup. The removed
  values are dropped from every frontend option list, schema and adapter,
  and coerced to "auto" at the Go link/sub/Clash emit sites and both link
  importers. Fix the CipherType_NONE sentinel that no longer compiles.

- Unencrypted vless/trojan outbounds to a public address are now refused
  by the core. Validate outbounds through the vendored config loader when
  saving the xray template and when storing/merging outbound
  subscriptions, so one such outbound cannot keep the core from starting.

- New TCP finalmask type "xmc" (Minecraft mimicry): add it to the sub
  link allowlist, the frontend enum and the FinalMask form (hostname,
  usernames, required password), and document it.

- streamSettings gained a "method" alias for "network"; canonicalize it
  to "network" at inbound save time and in the form adapters/schema so a
  method-keyed config keeps its transport.

- New root "env" config key is passed through xray.Config, compared in
  Equals, and forces a restart in the hot diff.

- REALITY now defaults minClientVer to 26.3.27; update the form
  placeholder.
2026-07-12 00:30:47 +02:00
MHSanaei cbd2940a63 fix(node): adopt a node inbound's host overrides into the master
Per-inbound Host overrides (Security/SNI/Fingerprint/ALPN and friends)
are looked up by the local inbound id when subscriptions render, but
nothing in the node sync ever fetched the node's hosts table: an
inbound adopted from a managed node got zero Host rows on the master,
so its subscription configs fell back to a bare TLS block without the
fingerprint/SNI the node was configured with.

When a traffic snapshot carries a tag with no central row yet - the
only moment adoption can happen - the sync job now also pulls the
node's existing hosts/list endpoint (best-effort, so old nodes just
skip it) and the adoption branch materializes that inbound's groups
against the new central id inside the same transaction, reusing the
group-to-rows projection the hosts API already uses. Master stays
authoritative afterwards: this is a one-time import, not a continuous
sync, matching how the inbound's own settings are adopted.

Closes #5890
2026-07-11 23:17:57 +02:00
MHSanaei e6bef229ae fix(web): opt panel pages out of Cloudflare Rocket Loader
Behind Cloudflare with Rocket Loader enabled, the panel's entry bundles
were rewritten and executed through Rocket Loader's own loader instead
of as native ES modules (a reporter's network capture shows the main
bundle initiated by rocket-loader.min.js). That breaks module semantics
and script ordering, leaving a blank page after login even though every
asset returns 200 - most visibly with a custom URI path, where the
injected base path must be set before the bundle boots.

Stamp data-cfasync="false" - Cloudflare's documented per-script opt-out
- on the built entry script tags via a build-time transformIndexHtml
hook (Vite regenerates entry tags, so a source-HTML attribute would be
stripped), and on the runtime-injected base-path/version inline script
in serveDistPage.

Closes #5868
2026-07-11 22:48:59 +02:00
MHSanaei 975b1f1acc fix(iplimit): ban a dead connection once instead of every scan
When a client's connection drops without a clean TCP close, xray-core
keeps its online-map entry until the session context ends (idle policy),
minutes after the kernel socket is gone. The 10s IP-limit scan kept
seeing that stale IP as the oldest live one and re-emitted the same
[LIMIT_IP] Disconnecting OLD IP line plus a RemoveUser/AddUser cycle
every scan - operators measured 100+ repeats over ~1000s for a single
network switch, forcing absurd fail2ban maxretry values to avoid
banning legitimate mobile users.

The core refreshes an entry's lastSeen only when a new connection from
that IP is dispatched, never on traffic, so a frozen lastSeen across
scans is a dead connection, not a reconnect. Track the lastSeen of each
banned (email, ip) pair and skip the log line and disconnect until it
advances; a real reconnect moves lastSeen and is enforced exactly as
before, and an age cutoff that could misclassify long-lived active
tunnels is deliberately avoided.

Closes #5893
2026-07-11 22:48:58 +02:00
MHSanaei 6aa87f4e57 fix(clients): finish deleting from every inbound when one fails
Delete aborted its per-inbound loop on the first error, so a client
attached to inbounds across several nodes lost at most one per attempt:
the loop never reached the remaining nodes, the record cleanup after
the loop never ran, and each retry started over with whatever was left.
Operators with many nodes had to delete the same client once per node.

Collect per-inbound failures and keep going so every reachable inbound
and node is cleaned in a single pass, then keep the client record only
when something failed - its settings JSON still holds the client there,
so the next delete retries exactly the leftovers - and return the
joined failures instead of silently reporting success. DeleteByEmail's
legacy fallback loop gets the same treatment.

Closes #5845
2026-07-11 22:48:57 +02:00
MHSanaei 200ea09157 fix(node): never sweep a node's inbounds before their first adoption
Adding a node imports nothing; its pre-existing inbounds only become
central rows on the first clean traffic-sync tick. But any save of the
node (switching sync mode, picking tags after "Load inbounds from
node") marks it config-dirty, and the next tick then ran ReconcileNode
before that first adoption: with zero central rows the delete sweep saw
every remote tag as undesired and destroyed the node's real inbounds -
in "all" mode all of them - disconnecting live clients with no
confirmation, and the master then reported "record not found".

Track the first completed clean sync in nodes.inbounds_adopted_at and
skip the sweep (pushes still run) until it is set, so "absent locally"
can no longer be conflated with "deleted on the master". A node that
has synced before still sweeps normally, including the offline
last-inbound-deleted case. Existing nodes are seeded as adopted on
upgrade to keep their behavior unchanged.

Closes #5898
2026-07-11 22:48:57 +02:00
MHSanaei c4448f4ea8 fix(clients): rename client record atomically with inbound settings
Update committed an email rename to the clients table with a standalone
write before the per-inbound loop rewrote each inbound's settings JSON.
In that window the record held the new email while the JSON still held
the old one, so any concurrent SyncInbound (traffic poll, another edit)
found no record for the old email and inserted a duplicate seeded from
the stale JSON - carrying the same subId, which then failed every later
edit with "Duplicate subId". The subId collision check also ran after
that write, so even a rejected update permanently renamed the email.

Move the rename inside UpdateInboundClient's serialized transaction,
next to the settings save and SyncInbound, so no other writer can see
one without the other; skip it when a record already owns the target
email (the merge case). Update now only runs collision checks before
the loop and falls back to a direct rename solely for records with no
attached inbound. This covers both the REST API and the web UI editor,
which share this path.

Closes #5870
2026-07-11 22:48:54 +02:00
MHSanaei 3b731cd657 fix(clients): reuse stored credentials when re-adding an existing identity
Create permits a repeat add for an email that already exists when the
payload subId matches the stored one (the documented way to attach an
identity to more inbounds), but it never seeded the payload from the
existing record, so an omitted id minted a fresh UUID via
fillProtocolDefaults. SyncInbound then overwrote the shared clients.uuid
row by email while previously-attached inbounds kept the original UUID
in their settings JSON, silently desyncing panel credentials from
subscription links. BulkCreate had the identical gap.

Seed ID/Password/Auth/Secret from the existing record in both paths
(mirroring what Update, Attach and BulkAttach already do), and preserve
Secret in Update too so partial edits of MTProto clients cannot rotate
the stored secret.

Closes #5903
2026-07-11 22:48:51 +02:00
mrnickson-hue ed9686bf29 fix(clients): include Telegram ID in client list search (#5888)
* fix(clients): include Telegram ID in client list search

clientMatchesSearch only checked Email/SubID/Comment/UUID/Password/Auth,
so searching the client list for a Telegram user ID never matched even
though the field is stored on every client.

This is a real regression, not a field that was simply never included:
before the paged search endpoint (#4500), the frontend searched with
ObjectUtil.deepSearch() over the full client object, which recursed into
every field including tgId. Replacing that with a fixed backend field
list silently dropped it (along with a few other fields, but tgId is the
one that's actually needed here since it's the panel's own way of
looking a client up when it only knows their Telegram ID).

TgID is int64 (0 = unset), so it can't sit in the existing []string
candidates array — matched separately via strconv, and skipped when 0 to
avoid a needle of "0" spuriously matching every client without a
Telegram ID.

Fixes #5880

* fix(clients): drop explanatory comment, mention Telegram ID in search hint

Addresses review feedback on #5888:
- Removed the // comment block above the TgID check in
  clientMatchesSearch per repo convention (code should read on its own).
- Updated searchPlaceholder in all 13 locale files to mention Telegram ID,
  since the search box now actually matches on it.

* test(clients): remove TgID search test per maintainer request
2026-07-10 12:20:35 +02:00
Sentiago 142dab9ee8 feat(balancer): add balancer-to-balancer fallback support (#5586)
* feat(balancer): add balancer-to-balancer fallback support

Xray does not natively support using a balancer as fallbackTag for
another balancer. This feature automates the loopback workaround:
when a user selects a balancer as fallback, the panel generates a
loopback outbound + routing rule in the template.

How it works:
- User picks fallback balancer from dropdown
- Panel creates loopback outbound _bl_{target} + routing rule
- Balancer fallbackTag set to _bl_{target}
- Traffic: Balancer A → loopback _bl_B → routing rule → Balancer B

Key features:
- Dedup: multiple balancers sharing same fallback reuse one loopback
- DFS cycle detection at edit time and on save
- Self-reference guard (cannot select own balancer)
- Delete protection (blocks if used as fallback by others)
- Cleans up routing rules referencing deleted balancers
- Override resolves balancer tags through loopback mechanism
- All live status tags resolved for display
- Internal _bl_ objects filtered from Outbounds/Routing UI
- Backward-compatible with old _bl_ naming format
- Translations for all 13 locales

* fix(review): override regression, save payload sync, i18n completeness

- OverrideBalancer: only resolve to loopback when resolution succeeds,
  pass original target through for plain outbound tags
- onSaveAll: serialize cleaned template before save to ensure the
  healed/cleaned config is what gets persisted
- Add reservedPrefix translation key to all 12 non-English locales
- Restore trailing newlines in all 13 translation JSON files

* fix(test): update balancer form modal tests after cycle-detection guard

The okButtonProps disabled guard (added in 56d5825c) prevents the modal
from firing onOk when the form is invalid. The old tests clicked the
button expecting validation errors to appear, but antd Modal never calls
onOk on a disabled button — causing false failures.

Rewrite to test the actual guard behavior:
- Button starts disabled (empty form)
- Stays disabled with tag only (selector still empty)
- Stays disabled for duplicate tag
- Disabled button does not trigger onConfirm

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-07-09 01:59:51 +02:00
Rouzbeh† ea24ef0a69 feat(xray): default outbound in basic routing (#5815)
* feat(xray): default outbound picker in basic routing

Let panel users choose which outbound handles unmatched traffic by
moving it to the first position in the template outbounds list.

* fix(xray): keep direct/blocked outbounds when changing default

* style(routing): revert incidental whitespace churn

Drop double blank lines and the reformatted function signature so the default-outbound diff stays focused on behavior.
2026-07-09 01:55:47 +02:00
Yuri Khachaturyan 2c28fa5f48 fix(inbound): scope port-conflict check to the stored node on update (#5833)
* fix(inbound): scope port-conflict check to the stored node on update

UpdateInbound called checkPortConflict before restoring the inbound's NodeID
from the database, so the check used the NodeID from the request body. That
value is unreliable for edits: clients omit it (nodeId is `json:",omitempty"`)
and the code already treats the stored NodeID as authoritative — an inbound
can't be moved between nodes via edit. With a nil request NodeID a node inbound
was mis-checked as a local/main-panel inbound and falsely collided with an
unrelated inbound that happened to reuse the same port on the central panel (or
another node). Symptom: editing a node inbound's listen address was rejected
with "port <p> (tcp) already used by inbound ... " and silently discarded.

Load the old inbound and restore inbound.NodeID *before* checkPortConflict, so
the check runs against the node the inbound actually lives on. checkPortConflict
already scopes candidates by node (sameNode); it was simply being fed the wrong
NodeID.

Add a regression test that seeds a main-panel and a node inbound on the same
port and asserts the node inbound stays editable (fails before this change with
the exact "already used" rejection).

* style(inbound): trim inline comments from port-conflict scoping

Repo convention forbids // line comments in committed Go; keep the scoping fix self-documenting.
2026-07-09 01:18:30 +02:00
Grigoriy cb5b3a803a fix(wireguard): build peers in GenXrayInboundConfig so node reconcile keeps clients (#5684)
Adding a WireGuard client on the master broke every WireGuard connection on
the sub-node until Xray was manually restarted on the node. Adding the same
client directly on the node worked.

Root cause: the panel stores WireGuard clients under the settings key
`clients` (the shape every other protocol uses), but xray-core's wireguard
inbound is configured with `peers`. The `clients`->`peers` conversion lived
only in the full-config generation path (XrayService.GetXrayConfig), which
runs on a full Xray restart. The live gRPC AddInbound path goes through
(*Inbound).GenXrayInboundConfig, which passed the WireGuard settings verbatim
- with `clients` and no `peers`.

Why the master path broke it and the node path did not:
- Adding on the node is a single safe operation: AddInboundClient -> AddUser
  -> AlterInbound{AddUser} -> wireguard.Server.AddUser, which appends one peer
  via IPC without touching the others. The inbound is local (NodeID == nil),
  so nothing is marked dirty and no reconcile runs.
- Adding on the master does two things: it pushes the client to the node
  (the same safe hot-add, which succeeds), and it marks the node dirty. The
  reconcile then pushes panel/api/inbounds/update/:id to the node, whose
  InboundService.UpdateInbound applies it live via DelInbound + AddInbound
  (buildRuntimeInboundForAPI -> Local.AddInbound -> GenXrayInboundConfig).
  That re-adds the wireguard inbound with zero peers, wiping the device and
  dropping every connected client. A manual restart regenerated the full
  config, converted clients to peers, and restored them - hence "only a
  restart fixes it".

Fix: convert WireGuard `clients` to `peers` in GenXrayInboundConfig itself,
the single chokepoint for every live AddInbound (create, edit, node
reconcile). WireguardClientsToPeers always rebuilds `peers` from `clients`
(matching GetXrayConfig field for field) and drops the `clients` key. It does
not gate on `peers` being absent: the panel seeds every WireGuard inbound with
an empty `peers: []` placeholder (frontend inbound-defaults), so a
"skip if peers present" guard would match that placeholder and make the
conversion never run, leaving the live path emitting zero peers. The
conversion stays idempotent by removing `clients`, so a second call - or an
inbound with no `clients` - is a no-op, leaving the full-config path
unaffected. This also fixes plain WireGuard inbound edits on a standalone
panel, which went through the same peerless rebuild.
2026-07-09 00:52:03 +02:00
Rouzbeh† b8a654967f Add encrypted DNS presets (#5837) 2026-07-09 00:45:35 +02:00
AmirRnz 42690e1b8c feat(hosts): bulk-add multiple hosts to multiple inbounds (#5677)
* feat(hosts): bulk-add multiple hosts to multiple inbounds

Allow users to select multiple inbound IDs and enter multiple host
addresses (with optional per-host port override) in a single form
submission.

- Add BulkAddHostReq entity and POST /panel/api/hosts/bulk/add endpoint
- Add AddHostsBulk service with GORM transaction safety
- Add parseHostAndPort helper (IPv4, bracketed/bracketless IPv6, port)
- Update HostFormModal to multi-select inbounds and tag-input hosts
- Wire bulkCreate mutation in HostsPage with existing-host suggestions
- Register endpoint in api-docs/endpoints.ts and regenerate OpenAPI/Zod

* feat(hosts): group override records by group_id and support group editing

* fix: import Popover in HostList

* fix: use messageApi in HostFormModal

* fix(hosts): resolve 4 bugs found in host-group code review

- fix(schema): allow empty hosts array in BulkAddHostSchema so users can
  save a host without an address (inherits inbound endpoint). The old
  .min(1) was never enforced at runtime since the schema is only used for
  type inference, but the type was incorrect.

- fix(service): validate new inbound IDs in UpdateHostGroup before deleting
  old rows, matching the same check already present in AddHostGroup. Prevents
  orphaned host rows when an invalid inbound ID is supplied on edit.

- fix(service): replace full-table scan in GetHostsByInbound with two
  targeted queries (DISTINCT group_id WHERE inbound_id=?, then
  WHERE group_id IN ?) to avoid loading every host in the DB.

- fix(mutations): remove unused createMut / create export from
  useHostMutations. The /hosts/add endpoint is identical to /hosts/bulk/add;
  only bulkCreate is used by the UI.

* fix(hosts): address code review feedback (optimize bulk inserts, add validation tests, and remove comments)

* fix(fmt): apply gofumpt formatting to model.go and db.go

The previous merge commit incorrectly applied gofmt (tab-aligned) to
these files. The repository's golangci config requires gofumpt+goimports
which produces space-aligned struct fields. This commit restores the
correct gofumpt formatting that matches upstream/main.

* chore(frontend): regenerate API schemas and update lockfile

* fix

* refactor(hosts): dedupe host-group service and tidy frontend

AddHostGroup and UpdateHostGroup shared an identical ~35-field
model.Host construction and hand-rolled transaction boilerplate
(tx.Begin plus a committed flag plus a deferred recover/rollback).
Extract buildHostRows, validateInboundsExist and formatHostAddr, and
run every mutation through db.Transaction. groupHosts collapses its
duplicated address/port formatting and create/append fork into one
path using slices.Contains. Behavior-preserving: host.go drops ~90
lines with the existing service/controller tests green.

Frontend: drop the Partial union and two as-casts in HostsPage.onSave
(the modal always passes a full BulkAddHostValues), and remove the
movable index map in HostList in favor of the table render index arg.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-08 23:35:20 +02:00
n0ctal f431e9cc03 fix(inbounds): apply runtime changes after the DB commit (#5768)
* fix(inbounds): apply runtime changes after commit

* ci: fix staticcheck findings
2026-07-08 22:12:28 +02:00
MHSanaei a067f817ae refactor: modernize Go with strings.SplitSeq and maps.Copy
Replace strings.Split loops with strings.SplitSeq iterators in the CSV
parsers (reality_scan and the scale-test helpers) and swap a manual map
copy for maps.Copy in the MTProto traffic collector. No behavior change;
these are the fixes the modernize analyzer reports.
2026-07-08 22:10:54 +02:00
n0ctal 7c183dbd97 fix(clients): surface bulk-reset auto-enable failures (#5763)
* fix(clients): surface bulk-reset auto-enable failures

BulkResetTraffic re-enables a disabled client before resetting its
traffic, but discarded the s.Update result with `_, _ =`, so a failed
re-enable was silent: the client stayed disabled with nothing logged,
unlike the single-client ResetTraffic path which already warns on the
same call. Check the error and log a warning to match, and add a
regression test covering BulkResetTraffic's previously-untested
re-enable path.

* ci: update Go toolchain for govulncheck

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-08 20:53:42 +02:00
n0ctal 567a4ac4fe fix(clients): parse only settings.clients across protocols (#5855)
* fix(clients): parse only settings.clients across protocols

Several inbound settings readers decoded the whole settings object into map[string][]model.Client. Real protocol settings include scalar keys such as VLESS decryption and Hysteria version, so that shape can fail before callers reach settings.clients or leave them relying on decoder side effects.

Add one shared helper that extracts only the clients field through json.RawMessage, then use it from GetClients, SearchClientTraffic and the IP-limit job fallback paths. Regression tests cover VLESS and Hysteria settings with scalar protocol fields.

* fix(clients): reject empty inbound settings
2026-07-08 20:31:00 +02:00
mrnickson-hue 7db92d6318 fix(inbound): reject finalmask + REALITY combo (crashes Xray-core) (#5861)
* fix(inbound): reject finalmask configured together with REALITY security

finalmask wraps the connection before REALITY's own handshake takes
over (TcpmaskManager.WrapListener -> WrapConnServer runs at Accept()
time, ahead of reality.Server()). reality.Server() does an unchecked
type assertion assuming a raw *net.TCPConn; with finalmask in front,
that assertion panics and takes down the entire xray-core process on
the very first connection to the inbound - not just that connection.

Upstream (XTLS/Xray-core#6453) confirmed this will be documented as
unsupported rather than made graceful, so the panel needs to stop this
combination from being saved rather than relying on docs.

AddInbound/UpdateInbound now reject streamSettings with
security=reality and a non-empty finalmask.tcp/udp with a clear error
instead of letting it reach Xray.

Related: MHSanaei/3x-ui#5857

* fix(inbound): heal legacy rows and narrow the finalmask+REALITY guard

Per review feedback on #5861:

- Narrow the check to finalmask.tcp only. xray-core's TcpmaskManager
  (the thing that wraps the TCP listener ahead of REALITY's handshake,
  the actual cause of the panic) is only constructed when tcp masks
  are present; a finalmask.udp-only config never touches that accept
  path and doesn't reproduce the crash, so it shouldn't be rejected.
  Extracted the shared check into finalMaskRealityTcpMasks() so both
  the save-time guard and the config-build heal below use one
  definition of "dangerous".

- Heal already-saved bad rows in GetXrayConfig(), the same way
  liftXhttpSessionIDKeys and HealShadowsocksClientMethods heal other
  legacy data at config-build time. AddInbound/UpdateInbound only cover
  the two save paths - a row that already carries this combination
  (saved before this guard existed, synced from a node, restored from
  a backup, or edited directly in the DB) would still crash Xray-core
  on the next restart without this.

- Add end-to-end tests exercising AddInbound, UpdateInbound, and
  GetXrayConfig directly (seeding rows through the real DB) rather
  than only unit-testing the extracted helper in isolation, so a
  wiring regression in any of the three call sites gets caught.
2026-07-08 20:29:51 +02:00
Volov Vyacheslav e424cc0f4d fix(routing): allow dns.servers on private IPs past the geoip:private block rule (#5774)
* fix(routing): allow dns.servers on private IPs past the geoip:private block rule

Xray's own DNS client traffic is dispatched through the same routing
table as proxied client traffic. When dns.servers points at a private
IP (e.g. a self-hosted AdGuard Home / Pi-hole reachable on the same
Docker network as Xray) and the panel's default geoip:private block
rule is active, Xray's own DNS lookups get silently dropped. Xray then
falls back to dialing destinations by raw hostname once its internal
DNS attempt times out (~4s), so proxied connections still work, just
with a multi-second stall added to every new domain-based connection,
with no error surfaced anywhere.

EnsureDnsServerRouting keeps a managed "direct" allow-rule for any
private literal IP found in dns.servers, inserted immediately before
the geoip:private block rule (matched by shape, not position). It only
acts when both ingredients are present, keeps the managed rule in sync
as dns.servers changes across saves, and never touches manually
authored rules.

Fixes #5773

* fix(routing): scope the DNS allow-rule to its port, guard against reorder/UI drift

Addresses three review findings on the initial fix:

1. The allow-rule now carries a "port" matcher (grouped by the
   dns.servers entries that share it), instead of opening every port
   on the private DNS IP to proxy-client traffic. A private resolver
   that also exposes an unauthenticated admin UI on the same address
   would otherwise become reachable through the proxy too.

2. EnsureDnsServerRouting now strips every previously-managed rule and
   rebuilds the current set fresh, reinserted immediately before the
   (re-indexed) geoip:private block rule on every save. Comparing IP
   content alone missed the case where an admin drags the rule below
   the block rule in the Routing tab (or reorders something else and
   incidentally moves it) — silently reintroducing the exact stall
   this fix addresses, with nothing to notice or correct it.

3. dnsAllowRuleShape now tolerates an "enabled" key as long as it's
   true, matching the existing EnsureStatsRouting precedent
   (xray_setting.go's `delete(apiRule, "enabled")`). The Routing tab's
   rule editor writes that key on every save regardless of whether
   anything changed, and its enabled switch writes it on a plain
   toggle — without this, either action permanently disowns the rule
   from management and a duplicate gets inserted next save. A rule
   explicitly disabled (enabled=false) is left alone and a fresh one
   is (re-)created, respecting the admin's choice instead of silently
   re-enabling it.

No-op detection now compares rebuilt rules against the original
routing.rules JSON (both decoded through encoding/json to a common
type) rather than reflect.DeepEqual on the parsed Go values, which
falsely reported changes for identical content stored as []any vs
[]string.

5 new tests cover multi-port grouping, position drift, and both
enabled-key cases; existing tests updated for the port field.

* fix: avoid size-computation overflow in allocation hint

CodeQL flagged make([]map[string]any, 0, len(clean)+len(managed)) as a
potential integer-overflow risk in the capacity computation. Drop the
addition and hint with len(clean) alone — it already covers most of
the eventual size, and append still grows correctly for the rest.

---------

Co-authored-by: Volov <volovdata@google.com>
2026-07-08 20:28:11 +02:00
mrnickson-hue 57300f44bd fix(ldap): convert default total GB to bytes when auto-creating clients (#5854)
* fix(ldap): convert default total GB to bytes when auto-creating clients

LdapSyncJob.buildClient stored ldapDefaultTotalGB directly into
Client.TotalGB without the GB-to-bytes conversion every other client
creation path applies (client form's gbToBytes, tgbot's
limitTraffic*1024^3, client_inbound_apply.go's totalGB*1024^3). A
"Default total (GB)" of 10 was persisted as 10 bytes, depleting the
client almost immediately.

Closes #5852

* test(ldap): pin the GB-to-bytes conversion in buildClient

Per review feedback on #5854: the existing test only exercised
defGB=0, so it wouldn't have caught the missing conversion.
2026-07-08 20:24:21 +02:00
MHSanaei 328d920e98 feat(mtproto): enforce per-client quota & expiry via mtg-multi limits
Map each mtproto client's totalGB and expiryTime onto mtg-multi's new
[secret-limits] (quota/expires): emit them into the generated config and
hot-apply through PUT /secrets so live connections survive. Quota is
written as an exact "<n>B" byte count that round-trips through both the
config and API parsers without the precision loss of a base-2 unit.

The sidecar's quota counter is not pruned when a secret is dropped, so a
panel-side traffic reset re-pushes the client's secret and then calls
POST /secrets/{name}/reset-quota (wired into every reset path) so a
renewed client is not immediately re-blocked.

Resolve the mtg-multi binary from the fork's latest release tag in
DockerInit.sh and release.yml instead of a hardcoded version pin, so the
panel no longer needs a manual bump per fork release.
2026-07-08 15:30:56 +02:00
MHSanaei bc309ed9f8 refactor(frontend): replace axios with the native Fetch API
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.

- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
  request/response interceptors on fetch: base-path prefixing,
  X-Requested-With, same-origin credentials, the CSRF token on unsafe
  methods, a single 403 retry with token refresh, and the 401
  redirect-and-latch. A small encodeForm() reproduces qs's
  arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
  envelope, so the ~49 API call sites are untouched. HttpOptions is now
  hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
  with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
  base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
  HttpUtil's envelope unwrap / toast / error mapping; this logic was
  previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
  reword stale "axios" mentions in docs and route comments.
2026-07-08 01:09:18 +02:00
Rouzbeh† 15faec6258 fix(logs): limit Xray log growth (#5840)
* Limit Xray log growth

* Apply suggestion from @github-actions[bot]

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Apply suggestion from @github-actions[bot]

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: Mahyar Dana <dana.mahyar76@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-08 00:33:34 +03:00
MHSanaei 2c49dbf54e fix(node): start a fresh quota window when a node auto-renews a client
When a node-hosted client auto-renews, the node extends the deadline
and zeroes its own counters, but the master treated the counter drop
like any reset dip (#5456): the delta clamped to zero, the renewed
expiry was adopted, and the old period's up/down stayed on the master
row. A "100 GB every 30 days" package never got a fresh quota on the
master for node inbounds.

Detect the renewal in setRemoteTrafficLocked - reset days configured,
an absolute deadline that moved forward, and the node counter falling
below the stored baseline - and on that path adopt the node's
post-renewal counters and enable state absolutely instead of adding
the clamped delta, plus clear the email's stale cross-panel
global-traffic rows, mirroring what the local autoRenewClients path
already does. A plain counter dip without a deadline move keeps the
existing clamp behavior, and a deadline extension with rising counters
keeps accumulating.

Closes #5843
2026-07-07 15:05:23 +02:00
MHSanaei 52d4af71bc fix(ldap): attach auto-created clients to every configured inbound tag
The sync job built an independent client per configured tag and called
CreateOne once per tag. Each call generated a fresh random subId, and
the email-uniqueness check in ClientService.Create only re-admits a
taken email when the incoming subId matches the stored one - so the
first tag succeeded and every other tag failed with "email already in
use", leaving new LDAP users on a single inbound.

Build the client once per email and hand ClientService.Create the full
list of resolved inbound ids, the same path the panel's own client
create endpoint uses: one identity (email, subId) attached to all
configured tags, with per-protocol credentials filled per inbound.
Unknown tags are now skipped with a warning instead of building
clients against a nil inbound.

Closes #5846
2026-07-07 15:05:22 +02:00
MHSanaei 7cb2adf429 fix(client): clean node_client_traffics rows when deleting a client
Delete and DeleteByEmail removed client_traffics, global-traffic, and
inbound_client_ips rows but never the per-node baseline rows in
node_client_traffics, so every deleted client left orphaned baselines
behind for each registered node. The shared DelClientStat and
delClientStatsByEmails helpers already clean that table; mirror the
same cleanup in both row-cleanup paths so the record-only and
record-less delete flows stop leaking baselines.

Closes #5841
2026-07-07 15:05:22 +02:00
ecgang 6e75938c61 [Feature]: Add a tooltip/hint to the "Password" field in the client form clarifying which protocols use it (#5809)
* feat(clients): clarify which protocols use the Password and Hysteria Auth fields

Add tooltips to the Password and Hysteria Auth Form.Items in the client
form, explaining that Password is only consumed by Trojan and Shadowsocks
(ignored for VLESS, VMess, Hysteria, WireGuard) and that Hysteria Auth is
the credential Hysteria actually uses. Adds passwordDesc/hysteriaAuthDesc
keys to all 13 locale files, following the existing limitIpDesc/totalGBDesc
tooltip convention.

Closes #5803

* test(clients): assert Password/Hysteria Auth tooltip hints render
2026-07-07 14:43:09 +02:00
MHSanaei ad7a0f8164 refactor(mtproto): manage ad-tags per client only
The inbound-level ad-tag duplicated the per-client override for no
gain: the fork's global tag applied to every secret anyway, so one
value had two homes and they could drift. The inbound form field, the
settings key, and the global ad-tag in the generated config and in the
PUT /secrets body are gone; the tag is set on each client instead.
Existing inbound-level values are intentionally not migrated; a
leftover settings key is stripped on the next save.
2026-07-07 12:19:26 +02:00
MHSanaei 43500a5470 feat(mtproto): per-client ad-tags, management-API auth, and record secret sync
Catch the panel up to the mtg-multi README (v1.14.0):

- Each client can now carry its own 32-hex advertising tag overriding the
  inbound-level one. The tag lives on the client (settings JSON is the
  source of truth, clients.ad_tag is the UI projection), is rendered into
  the fork's [secret-ad-tags] section for active secrets only (mtg rejects
  a config whose override names an unknown secret), is pushed per entry
  through PUT /secrets, and is part of the reload fingerprint so a tag
  edit hot-applies without dropping connections.
- The loopback management API can replace the whole secret set, so every
  mtg process now gets a random per-process api-token; the manager sends
  it as a bearer token on PUT /secrets and GET /stats and reuses it across
  config rewrites, because mtg reads the token only at startup.
- Malformed tags are rejected at every save path and additionally dropped
  in InstanceFromInbound: one bad tag would otherwise fail the whole
  generated config and take every client of the inbound down with it.
- SyncInbound never copied a re-keyed mtproto secret into the canonical
  clients table, so the clients page and subscription links kept serving
  the old secret, which mtg then rejects. It is now guarded-copied like
  the other credentials.
2026-07-07 12:00:43 +02:00
Sanaei 6214ff4edc fix(mtproto): stop dropping connections on client/inbound edits; add live updates + ad-tag (#5838)
* fix(mtproto): split the mtg fingerprint into structural and secrets parts

A reordered clients array in the stored settings used to read as a config
change because the fingerprint concatenated secrets in array order, and one
opaque fingerprint could not tell a restart-worthy change (bind address,
fronting, throttle) from a secret-set change a reload-capable mtg can absorb
in place. Sort the secret pairs so order stops mattering, and split the value
so the upcoming hot-reload path can decide between keeping, reloading, and
restarting the process.

* fix(mtproto): stop restarting mtg on every inbound edit

Saving an mtproto inbound tore down and respawned its mtg sidecar even when
nothing material changed, dropping every live Telegram connection: the update
path pushed DelInbound+AddInbound, and Remove deletes the manager's map entry,
so Ensure's fingerprint no-op gate could never fire. Route mtproto updates
through a single Ensure call so an edit that leaves the generated TOML alone
keeps the process, and only real config changes restart it.

Capturing the pre-edit protocol also fixes a latent leak: changing an
inbound's protocol away from mtproto never stopped the sidecar, because the
snapshot handed to the runtime already carried the new protocol and the
removal took the xray branch, leaving an orphaned mtg holding the port.

An mtproto push failure no longer requests an xray restart - xray cannot fix
the sidecar, and the 10s reconcile job self-heals it.

The regression test fakes mtg by re-executing the test binary, counting
spawns through a pid file: an unchanged save and a remark-only edit must keep
the process, a re-keyed secret must restart it.

* fix(mtproto): exclude depleted clients from the reconcile job to match the sync push

The 10s reconcile job derived mtg secret sets from raw inbound settings while
the interactive push filtered clients through buildRuntimeInboundForAPI, which
drops client_traffics-disabled (depleted or expired) clients. The two paths
therefore disagreed on the fingerprint - each disagreement one needless mtg
restart dropping live connections - and worse, the job kept serving depleted
clients' secrets indefinitely, so running out of traffic never actually cut an
mtproto client's access.

DesiredMtprotoInstances now builds the job's desired state with the same
depletion overlay the push uses (one bulk client_traffics query), drops
inbounds whose every secret is filtered away so their sidecar stops, and
AddInbound pushes the filtered payload too so an imported inbound carrying
disabled stats does not seed a fingerprint the next reconcile disagrees with.

* feat(mtproto): hot-reload mtg secrets in place instead of restarting

A client add, removal, re-key, or enable-toggle changes only the [secrets]
section of the generated config, yet the panel could apply it only by killing
and respawning the mtg sidecar, dropping every Telegram connection on that
inbound. Split the ensure decision three ways: an identical config is a no-op,
a secrets-only change rewrites the TOML on the same api port and asks mtg to
hot-swap it via POST /reload, and a structural change (or a failed reload)
falls back to the full stop-and-start.

The reload endpoint is served by the mhsanaei/mtg-multi fork; against an older
binary the POST 404s and the manager restarts exactly as before, so panel and
binary upgrades stay order-independent.

* feat(mtproto): apply single-client edits to the sidecar immediately

Client CRUD on an mtproto inbound was a runtime no-op, so an add, delete,
re-key, or enable-toggle only reached mtg on the next 10s reconcile. With the
sidecar now able to hot-reload, push the change straight after the edit commits:
applyLocalMtproto rebuilds the inbound's filtered client set and re-applies it,
so a new client works within a moment (and, on a reload-capable binary, without
disturbing the others) and deleting the last client stops the process.

The three interactive single-client paths (add, update, delete) call it; bulk
operations still ride the reconcile job, which converges to the same state.

* chore(mtproto): pin mtg-multi to the mhsanaei fork v1.13.3

The reload endpoint the panel now uses lives in the mhsanaei/mtg-multi fork, so
point the source-build pin (DockerInit.sh + both release.yml matrices) at it and
bump to v1.13.3. The install still produces the same mtg-multi binary name, so
the mtg-<os>-<arch> rename and everything downstream are unchanged. Docs and the
package comment note the hot-reload path and its restart fallback.

* feat(mtproto): apply live secret updates via the management API and add ad-tag

Two capabilities the mhsanaei/mtg-multi v1.13.3 fork exposes are now surfaced by
the sidecar manager.

Live updates go through PUT /secrets on the fork's management API instead of
POST /reload: the panel already holds the whole desired set per inbound, so it
sends secrets and the advertising tag as one JSON call that mtg applies
atomically, keeping every unchanged connection and closing only removed or
re-keyed ones. The config file is still written first so a restart or crash
recovery reproduces the state, and any non-200 (an older binary, a refused
connection) still falls back to a full restart.

Per-inbound ad-tag adds an optional 32-hex Telegram advertising tag plus
public-ipv4/public-ipv6 overrides. The ad-tag rides the reloadable secrets
fingerprint, so changing it hot-applies without dropping connections; the public
IPs are proxy-construction parameters and sit in the structural fingerprint, so a
change there restarts the process. Empty public IPs are omitted so mtg
auto-detects the reachable address.

* feat(inbounds): expose the mtproto ad-tag and public IP in the inbound form

Adds an Ad-tag field (validated as 32 hex characters) plus optional Public IPv4
and Public IPv6 overrides to the MTProto inbound form, backed by the same-named
settings the sidecar writes into the mtg config. The public IPs are optional —
left blank, mtg auto-detects the reachable address the ad-tag middle proxy needs.
English strings are added to every locale; the non-English ones carry the
English text until translated and fall back to it meanwhile.

* ci(mtproto): install mtg-multi from prebuilt release binaries

The fork now publishes release archives for every platform we package, so
download and unpack the matching mtg-multi-<ver>-<os>-<arch> binary instead of
compiling it from source with go install. Faster builds and no toolchain step,
and the archive's platform labels line up with our matrix; the produced
mtg-<os>-<arch> filenames are unchanged.

* i18n(mtproto): localize the ad-tag and public IP strings

The six mtgAdTag*/mtgPublicIp* keys shipped with English text in every locale as
a placeholder. Translate them into the twelve non-English locales (Arabic,
Spanish, Persian, Indonesian, Japanese, Portuguese-BR, Russian, Turkish,
Ukrainian, Vietnamese, and Simplified/Traditional Chinese); en-US is unchanged.

* retired goreportcard.com
2026-07-07 01:13:24 +02:00
MHSanaei 84b6423020 fix(mtproto): stop persisting a vestigial inbound-level secret
MTProto is multi-client: mtg's [secrets] config and every share link read only the per-client secrets. The old HealMtprotoSecret regenerated an inbound-level secret on every save, and seedMtprotoSecretsToClients only dropped it for legacy single-secret inbounds, so multi-client inbounds kept a dead secret. That value once leaked into stale links imported into Telegram, which mtg then rejected as "incorrect client random".

Replace HealMtprotoSecret with StripMtprotoInboundSecret (removes the key), strip on save in normalizeMtprotoSecret, and add a one-time stripMtprotoInboundSecrets migration that runs after the seeder so a legacy secret is first preserved onto a client before the inbound-level copy is dropped.
2026-07-06 17:55:57 +02:00
MHSanaei d97bd8643e feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client
Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound.

The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates.
2026-07-06 16:04:32 +02:00
MHSanaei f36f481e02 feat(db): add pgclient command to install or upgrade PostgreSQL client tools
Restoring a panel backup made by a newer pg_dump fails when the host's
pg_restore is older, and the existing pg_ensure_client only installs the
distribution package when the tools are missing - it can never upgrade,
and distribution repositories often cap below the required major.

Add pg_upgrade_client to x-ui.sh, exposed as 'x-ui pgclient [major]' and
as a PostgreSQL menu entry: it checks the installed pg_restore major,
tries the distribution package for the exact requested major first, and
falls back to the official PostgreSQL repository (apt on Debian/Ubuntu,
yum/dnf on Enterprise Linux, with a /usr/pgsql PATH symlink fallback);
Arch, Alpine and openSUSE install their current package. The panel's
dump-version mismatch error now names the ready-to-copy command with
the exact major parsed from the dump header.
2026-07-06 09:24:18 +02:00
MHSanaei de70ecb026 fix(db): probe dump readability before PostgreSQL import
pg_restore cannot read archives newer than itself, so importing a dump
made by pg_dump from PostgreSQL 17+ into a panel with an older
postgresql-client failed with a raw 'unsupported version (1.16) in file
header' - and only after Xray had already been stopped for the restore.

Probe the uploaded file with pg_restore --list first, which reads only
the archive TOC without touching the database, so an unreadable dump is
rejected before Xray is interrupted. When the failure is a dump-format
version mismatch, translate it into a message naming the PostgreSQL
version that produced the dump and the client version to install.
2026-07-06 09:01:19 +02:00
MHSanaei ed66209e38 feat(outbound): add real-delay connection test mode
The HTTP probe reports the warm per-request round-trip, which reads
lower than the delay figure client apps show for the same server. Add a
third "real" test mode that reuses the temp-instance HTTP probe but
reports the cold request's full elapsed time - tunnel establishment
included - and skips the warm request. UDP-transport outbounds forced
out of the TCP lane still report "http"; in real mode they report
"real". The mode joins the TCP/HTTP toggle on the outbounds tab, with
the label translated in all 13 locales.
2026-07-06 08:35:48 +02:00
MHSanaei 5a7b3b7370 fix(client): stop duplicate client entries accumulating in inbound settings
Adding a user to multi-node inbounds could leave 3-6 identical entries
in one inbound's settings.clients array: addInboundClient appended
incoming clients unconditionally, and the duplicate-email precheck
exempts a matching subId (so one identity can span several inbounds),
so a retried or raced add of the same client re-appended it to an
inbound that already carried it - on the master and, since nodes run
the same code, on every node, whose snapshot adoption then copied the
duplicates back verbatim. The normalized clients/client_inbounds tables
stayed clean (unique constraints), which is why the phantom rows only
showed in settings-driven views like the Detach clients modal, where
duplicate React keys also broke the selection counter.

Three layers: addInboundClient now skips incoming clients whose email
is already on the target inbound (idempotent re-adds instead of
duplication), node snapshot adoption collapses duplicate emails before
writing the central row, and an idempotent startup repair rewrites any
inbound whose settings still carry duplicates from older builds.

Closes #5770
2026-07-05 21:17:25 +02:00
MHSanaei 837cf5f24e fix(db): clamp traffic counters below int64 max and repair overflowed rows
A counter pushed past int64 (multi-node setups hit this via historic
delta-compounding bugs) makes SQLite silently promote the INTEGER cell
to REAL. From then on the column no longer scans into the Go int64
field and every reader of client_traffics fails at once: the inbounds
page, xray restarts, and node traffic sync all return "converting
driver.Value type float64 to int64".

Two-part fix: every unbounded "up = up + ?" add (local traffic, node
delta merge, inbound counters, plus the Go-side outbound accumulation)
now saturates at TrafficMax, a cap safely below math.MaxInt64 so one
more delta cannot overflow; and a startup repair casts REAL-promoted
cells back to INTEGER and clamps all traffic counters into
[0, TrafficMax] across client_traffics, inbounds, outbound_traffics
and node_client_traffics, restoring access to already-corrupted panels
without manual sqlite surgery.

Closes #5762
2026-07-05 20:33:09 +02:00
MHSanaei b1fa76f9b6 fix(node): fully delete clients on nodes instead of only detaching them
Deleting a client on the master propagated to nodes via the detach
endpoint, which removes the client from that one inbound's settings but
deliberately keeps the client record. The node ended up with an
orphaned record that kept showing in its Clients view; the master and
node could never converge on a delete.

Full-delete and detach intent now travel separately: the Runtime
interface gains DeleteClient, which on Remote hits the node's
panel/api/clients/del endpoint (record, attachments, traffic; repeat
calls for a client on several inbounds of the same node are swallowed
as idempotent "not found"). Delete/DeleteByEmail/BulkDelete use it for
node inbounds, while Detach/BulkDetach keep the inbound-scoped detach
RPC so removing a client from one inbound never wipes it node-wide
(the #5543 guarantee is preserved and covered by tests). Bulk deletes
above the fold threshold still converge membership via reconcile; their
leftover node records can be cleaned with the node's delete-orphans
action.

Closes #5797
2026-07-05 20:28:26 +02:00
MHSanaei b6873c7a73 fix(outbound): measure HTTP test delay on a warm connection
Since the batched prober replaced the single tester, the reported delay
came from one cold request with keep-alives disabled, so it stacked the
SOCKS handshake, proxy dial, proxy TLS, target TCP and target TLS on top
of the round-trip. Users upgrading from v2.9.4 - whose tester warmed the
connection first and timed a second request - saw several times the real
connection time.

The cold request still proves reachability and supplies the HTTP status
plus the connect/TLS/TTFB breakdown; the delay is now re-measured on a
second request over the kept-alive connection, falling back to the cold
total when the warm request fails. Bodies are drained (bounded) so the
connection returns to the pool, and the batch test asserts both requests
of a probe share one connection.
2026-07-05 20:19:25 +02:00
MHSanaei b6183271da fix(tgbot): find clients by tgId regardless of settings JSON formatting
The Telegram-bot usage lookup prefiltered inbounds with
settings LIKE '%"tgId": N%', which requires the exact space the panel's
MarshalIndent happens to emit. Inbounds whose settings were serialized
compactly (node sync, imports, external edits) never matched, so the
bot reported no configuration even though the client and traffic rows
exist. Replace the string match with the driver-portable JSON helpers
already used by GetAllEmails, which read the actual clients array on
SQLite and Postgres alike.

Closes #5805
2026-07-05 20:18:59 +02:00
MHSanaei a0989e0f4d fix(node): stop client edits from tearing down node inbounds and harden reconcile fingerprints
A client save on the master always stamped a fresh updated_at, marked
the node dirty, and let the 5s sync push a full inbounds/update to the
node, where applying it removes and re-adds the Xray handler - killing
live traffic on every edit, including no-op saves (open the editor,
click Save). Nodes stayed online with Xray running while forwarding
nothing until a manual Xray restart.

- No-op client saves preserve the client's updated_at and return before
  any DB write, runtime RPC, or node dirty mark when the effective
  settings did not change.
- Successful per-client add/update/delete pushes advance the node's
  reconcile-skip fingerprint only when the recorded fingerprint proves
  the node held the exact pre-edit payload and every push in the edit
  succeeded (Remote.AdvancePushedInbound). Anything unproven keeps the
  stale fingerprint so the dirty reconcile still sends the full inbound.
  Unconditional stamping would certify folded bulk changes (threshold,
  flow change, offline edit) or partially failed batches as delivered:
  a folded 41->6 bulk delete followed by one live edit left the node
  permanently serving all 41 clients in end-to-end testing, with the
  snapshot adoption then resurrecting the deleted clients on the master.
- DeleteUser treats only an envelope-level not-found as already deleted;
  an HTTP 404 from an old node build without the detach endpoint
  surfaces as an error instead of certifying an undelivered delete.
  cacheDel drops the fingerprint alongside the id cache so DelInbound
  and tag renames leave no stale skip entry.
- Adopting the node's own settings serialization into the master row now
  also stamps the fingerprint (RecordAdoptedInbound). Without it the
  serialization round-trip invalidated the fingerprint one sync tick
  after every push, so each edit degraded back to a full teardown push.
- UpdateInboundClient applies the Shadowsocks method normalization
  before the no-op comparison (real method changes bump updated_at, SS
  no-op edits are detected) and syncs the generated subId into the
  pushed client so the node cannot mint a different one.

Verified with a two-panel docker deployment: no-op saves produce zero
node requests, real edits send one lightweight clients/update RPC with
zero full inbound updates and zero handler teardowns, and folded bulk
deletes still converge.

Based on PR #5778 by @rqzbeh.

Closes #5764
Closes #5771
2026-07-05 02:06:58 +02:00