* Bound the syslog view with a journalctl timeout
* fix(syslog): bound the journal scan window and soften the timeout message
Limit journalctl to the last 30 days so a rare -p level cannot scan the whole
journal, and drop the guessed cause and the host-wide vacuum advice from the
timeout message.
* fix(syslog): drop --since from the journalctl call and test the timeout
On systemd 249/252 (Ubuntu 22.04, Debian 12) journalctl seeks to --since
and reads forward when both --since and -n are given, so the Syslog view
showed the oldest 200 lines of the 30-day window instead of the newest.
Reproduced in debian:12, ubuntu:22.04 and ubuntu:24.04 containers on a
synthetic journal; only 255 kept the newest lines. The window also bought
nothing: on a 340 MB journal every variant (with or without --since, rare
-p level or not) answered in ~10 ms on 252 and 255.
Keep the 15s deadline as the guard against a stalled journalctl and cover
it with a fake journalctl on PATH; the args test pinned the broken flag
and is removed.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* fix(runtime): reset node inbound traffic by node-side id
Remote.ResetInboundTraffic posted to the master's inbound id (ib.Id),
while every other node-side inbound call resolves the id assigned by the
node from the tag. The reset therefore hit an unrelated inbound or failed
silently (warning only), so the panel reported success either way.
Resolve the id through resolveRemoteID(ctx, ib.Tag) and fail before
posting when the tag cannot be resolved.
Fixes#6713
* fix(job): list the inbound on the periodic-reset fake nodes
Remote.ResetInboundTraffic now resolves the node-side id from the tag via
panel/api/inbounds/list before posting resetTraffic. The fake nodes in
periodic_traffic_reset_nodes_test.go answered that list with no obj, so the
tag never resolved, no reset reached a node, and
TestPeriodicResetReachesInboundNodesConcurrently failed (green on main, red
after merging the node-side-id fix). Each fake node now lists the inbound it
hosts, so the test again measures concurrent resets against a node shaped
like a real one.
---------
Co-authored-by: Кот <kot@zeroclaw.local>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* fix(sub): keep serverNames out of a reality host's JSON client config
A host with an SNI on a REALITY inbound set both serverName and the
server-side serverNames list on the per-host stream. Both the JSON and
Clash renderers have already reduced the stream to its client form by
then, so serverNames was never read, and the JSON subscription shipped
it in the proxy outbound. xray-core refuses to start that config
("non-empty serverNames, please use serverName instead"), which breaks
every JSON-subscription client on the inbound. Set serverName only.
Fixes#6690
* docs(sub): keep the host reality SNI comments within two lines
Same two-line comment cap the #6694 review applied.
* fix(sub): keep the spider settings in a reality spiderX seed's query
xray's REALITY client reads p, c, t, i and r from the spiderX query as
its spider's own settings (padding, concurrency, times, interval,
return). deriveSpiderX hashes the whole seed into a bare /path per
client, so any query set on the inbound was dropped from every share
link and JSON subscription, and the spider always ran with defaults.
Keep the seed's query after the derived path. The hash input is
unchanged, so every existing client's spx stays the same; only seeds
that carry a query gain it. The frontend mirror and the cross-language
vectors are updated together.
* docs(sub): keep the deriveSpiderX comments within two lines
Review feedback on #6694: the added lines pushed both doc blocks past the two-line cap.
The node gate assumed a node-assigned sidecar row would never converge,
because the master's reconcile loops only read node_id IS NULL rows. But
a pushed row is local on the node's own panel, whose AmneziaWG, TUIC and
mtg loops run it like any other; node-adopted rows of these protocols
already worked. Only creating or cloning them from the master was blocked.
Open the three protocols on both lists and fix what assumed the master's
host for a node row:
- A node older than the release that introduced the protocol (MTProto
v3.5.0, AmneziaWG v3.7.0, TUIC v3.8.0) would hand it to Xray as-is, so
add and protocol-changing update refuse it, and a node that has not
reported its version yet. Dev builds ("dev+<sha>") track main and pass.
- MTProto's routeXrayPort is a loopback port on the host running mtg.
The master no longer allocates one, nor forwards a cloned source's, for
a node row; the node allocates its own and node sync adopts it back.
- AmneziaWG forwardedPorts were checked against the master's inbounds,
web port and id-derived relay ports. A node row is now checked against
its own node's inbounds; the node re-checks what only it knows.
UpdateInbound restores the stored nodeId before that check.
Verified on a docker master+node pair: AWG, routed MTProto and TUIC
created and cloned from the master start on the node (interface, mtg,
tuic-server); an AWG client added and an MTProto client edited on the
master apply on the node; the node-chosen egress port survives edits.
Closes#6306
.nvmrc and frontend/package.json engines moved to Node 26 / npm 11 and the
lockfile is now written by npm 11, but the Dockerfile's frontend stage stayed
on node:22-alpine. npm 10 rejects that lockfile ("Missing: msw@2.15.0 from
lock file"), so `npm ci` fails and the image no longer builds.
Windows Firewall prompted on every run of amneziawgnet.test.exe, since
the tests opened AmneziaWG, outbound-client and port-forward sockets on
all interfaces and go test rebuilds the binary under a fresh temp path,
so a granted exception never sticks.
Route every "all interfaces" bind through wildcardBindHost, which is
empty in production (behaviour unchanged) and pinned to 127.0.0.1 by
the package TestMain.
The node-assignment path in the service package needs the same
MAJOR.MINOR.PATCH comparison the panel updater uses, but service/panel
imports service, so the helper cannot stay there.
* fix(api-token): keep a token's scope when the CLI regenerates it
RecreateByName deleted the named row and created a new one without a Scope,
so the insert took the column default of admin. Since -tokenName lets the CLI
regenerate any token, rotating a monitor or node-sync token silently turned it
into a full-access one.
The replacement now takes the scope of the row it replaces, and a new name
still gets admin as before. A stored scope this build does not know, as after
a downgrade, fails the rotation and leaves the row alone instead of guessing.
Assisted-by: Claude Code:claude-opus-5-5 (mostly)
* feat(cli): let -getApiToken choose the scope of the token it issues
-tokenScope sets the scope on both branches of -getApiToken: the token minted
on a fresh panel and the one regenerated on a populated panel. Without the flag
a regenerated token keeps its scope and a new one gets admin, so every existing
invocation, install.sh included, behaves as before.
An unknown scope is refused before anything is deleted, so a typo cannot
revoke the token it meant to rotate.
Assisted-by: Claude Code:claude-opus-5-5 (mostly)
* fix(api-token): keep a token's expiry when the CLI regenerates it
RecreateByName built the replacement row with ExpiresAt 0, so running
`x-ui setting -getApiToken -tokenName <name>` on a token issued through
the API with a deadline handed back one that never expires, and said
nothing about it - the same silent widening this branch fixed for scope.
The replacement now carries the replaced row's ExpiresAt. A token whose
deadline has already passed is refused instead of rotated, since keeping
the deadline would mint a dead token and dropping it would revive an
expired credential without limit; the expired row is left untouched.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* Fix fragment exports for older Xray clients
* fix(link): tolerate a finalmask without tcp in panel share links
withLegacyFragmentRanges called finalmask.tcp.map unguarded, but stored
rows reach the link generator unparsed and dropEmptyFinalMask deletes an
empty tcp list on save. Any VMess/VLESS/Trojan/SS inbound with only UDP
masks or quicParams threw a TypeError in the QR, info and export-links
views. The Go counterpart already skipped a missing tcp.
Also trims the Go helper's comment to the two-line cap and drops a
[]string branch no JSON-decoded finalmask can reach.
---------
Co-authored-by: Artem K <a.kush@vkteam.ru>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
The bot never read the replies under its own findings, so a finding a
maintainer had already declined came back on the next `@claude review`.
It now reads every comment and inline thread first: a maintainer's answer
settles a finding for good, anyone else's is a claim checked against the
code, and the summary gives each earlier finding a disposition. It also
reads the issue the PR claims to fix and reports a partial fix.
REVIEW.md asks for an upstream symbol behind every wire-format claim, but
the job had no xray-core source (#6718's review said so). The module the
base go.mod pins is now unpacked into a hidden dir in the base workspace;
nothing from pr-head runs.
REVIEW.md gains the rules only /senior-review carried: keep read,
reproduced and inferred claims apart, evidence for performance findings,
a traced trust boundary for security ones, and duplicated logic as a
finding. The summary now says each inline finding in one line.
65b9bfed narrowed the fix carve-out to "one clause naming WHERE the fix
belongs", but the bot still closes every finding with that clause, and set
beside the defect it already named, the location is the fix. On #6718 it
listed the two capabilities the bounding set lacks, then wrote "The fix
belongs in the capability bounding set". Drop the carve-out from REVIEW.md
and the workflow prompt: the finding's file:line already says where.
* fix(sub): carry REALITY ML-KEM hint in VLESS links
Keep raw share links in parity with Clash subscriptions for Xray 26.9.8+. Preserve the URI hint through Go and frontend imports, expose it in the outbound editor, and update the documentation tooling.
* fix(link): accept REALITY ML-KEM boolean aliases
* test(frontend): isolate Happ preset notifications
* fix(link): keep the ML-KEM hint out of Xray REALITY settings
support-x25519mlkem768 is a Mihomo reality-opts option; xray-core's
REALITYConfig (infra/conf/transport_security.go) has no such field and its
JSON loader drops unknown keys silently. The PR also stored it as
realitySettings.supportX25519Mlkem768 in Xray outbounds (form switch, Go and
TS link import, docs outbound builders) and as an inbound settings default
that is stripped before Xray and read by no link generator. The outbound
switch therefore did nothing, and imported links carried a dead key into the
JSON subscription.
The share-link hint itself stays: Go, frontend and docs still emit
support-x25519mlkem768=true on VLESS REALITY links and drop it on a TLS host
override.
---------
Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
CodeQL (go/allocation-size-overflow, alerts #115/#116) flagged the
len(rawDomains)+len(rawResolvers) capacity hint. The sum cannot overflow
in practice, but the hint bought nothing: resolver-derived domains are
deduplicated and append grows the slice as needed.
Refresh Go, frontend, and documentation dependencies, including MSW 3 and pnpm 12.8.1. Update the MSW test setup to use the renamed `onUnhandledFrame` option.
Bump xtls/xray-core to b26a91de4f (v26.9.30) and the three binary pins
(DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted
symbols; the sing and sing-shadowsocks indirect deps drop out with the
SS2022 rewrite.
XDNS finalmask (#6718) replaced its string lists with objects: domains
are {name, types, edns0, lenLimit, labelLimit} and client resolvers
{type, settings.addr}. The loader no longer parses the old lists, so a
single stored xdns mask keeps the whole core from starting. The new leaf
package internal/util/maskcompat converts them: "name[:type]" becomes a
domain and "name[:type]+udp://addr" a domain plus a udp resolver. A bare
name maps to TXT, the type legacy clients queried by default, and each
converted domain keeps the 1232-byte EDNS0 the old code always used
(without it the server caps answers at 512). It runs from:
- the XdnsFinalmaskObjectsFix seeder, over inbound streams, hosts, the
xray template, the global sub-JSON mask and cached subscription
outbounds;
- inbound save (normalizeStreamSettings) and GetXrayConfig, for rows
that never went through the seeder;
- both link importers, since fm= from an older panel carries the lists.
The finalmask form edits the object shape (every key needs a registered
field, or the finalmask watch drops it on save) and lifts legacy masks
on open. The udp-mask golden fixture moves to the object shape, which
TestGoldenStreamFixturesBuildInXray now builds through the core. The
wire format changed as well, so pre-upgrade clients need the new core.
WireGuard outbound (#6771) dropped settings.domainStrategy and the
remoteDNS "local" mode. The endpoint lookup now follows
sockopt.domainStrategy and in-tunnel targets the outbound's
targetStrategy. The old key is silently ignored, which undid the
IPv4-first endpoint lookup the WARP outbound depends on (#5205), and
"local" now panics the core at startup because remoteDNS goes through
netip.MustParseAddr. The WireguardDomainStrategyFix seeder moves a stored
family preference to both keys (a value already set wins) and turns
"local" into targetStrategy; the outbound form lifts legacy rows the same
way and drops its select, the WARP modal writes the new placement, and
the inbound form loses a field the server never read.
ValidateOutboundConfig now refuses a non-IP remoteDNS entry, which
conf.Build() lets through, on template save and for outbound
subscriptions.
Noise finalmask items accept type "exp" (#6862), a tag expression. The
form offers it for noise items only: header-custom items go through the
core's PraseByteSlice, which refuses it.
TUN gained autoSystemDnsToGateway (Linux) and autoSystemWfpBlockLeak
(Windows). Both pass through the settings schema so a value set in JSON
survives the next form save.
MASQUE (inbound, outbound, transport) and the XDRIVE transport are new
protocols the panel does not offer yet; their new loader refusals only
cover configs the panel never generates. The FakeDNS IPv6 pool default,
the SS2022 rewrite (same gRPC account; emails are now deduped
case-insensitively, as the panel already does), the restored udphop
interval default and the rest change no panel-facing config.
FileManager typed every download text/plain. Android's MediaStore appends
the MIME type's extension whenever the name's own extension maps elsewhere,
so a subscriber saving a WireGuard config got peer.conf.txt, which the
WireGuard app refuses; .json, .yaml and .log downloads were renamed the same
way. Desktop browsers honour the download name, which is why only phones
saw it. application/octet-stream carries no extension of its own, so the
name the panel chose is kept.
Invariant: an inbound's enabled Hosts are the endpoints every client config
for it advertises, whichever surface renders that config.
WireGuard and AmneziaWG broke it. Their raw generators ignored the
externalProxy entries Hosts are injected as and always emitted
resolveInboundAddress, so the raw subscription, the sub page .conf, the
clients links API and "export all links" gave out the panel address while
the JSON and Clash formats of the same inbound used the Host.
advertisedEndpoints now states the fan-out once for mtproto, wireguard and
amneziawg.
The browser-built configs had the same gap. The Clients page WireGuard and
AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the
panel hostname next to server links that already used Hosts; the Inbounds
page peer configs, QR and export ignored them too. withMtprotoHostEndpoints
becomes withHostEndpoints over a shared hostEndpointsFor mirror of the
backend, the tunnel fan-outs render one config per Host, and the clients
page waits for the hosts list the way the inbounds page does, so an empty
list means "no hosts" rather than "not loaded yet".
Invariant: saving an inbound's configuration never changes which clients it
holds nor whether it is enabled; both have their own endpoints. The edit
modal posts back the clients and the enable flag it loaded when it opened.
A client added meanwhile (another admin, the bot, the API, LDAP) was
detached and its stats deleted; a client deleted meanwhile came back with
its credentials, restoring access that had been revoked; an inbound
switched off meanwhile was switched back on.
For every save but a master's node-sync push, UpdateInbound now takes the
client list and enable from the row it re-reads inside the writer; this
replaces the lifecycle-only carry from the previous commit. Client
validation (renewal schedule, Hysteria auth, TUIC credentials) moves after
that swap so it judges the clients actually saved: a protocol switch keeps
the stored clients, and #6268's refusal must apply to them.
The edit form no longer loads or sends clients, so neither the JSON editor
nor validation sees a copy the server ignores, and the enable switch shows
only when adding; the list toggle (/setEnable) covers existing inbounds.
Tests that added or re-keyed clients through a panel inbound save pinned
the old rule; they now drive the master-push path, where payload clients
still apply.
Invariant: the sidecar's quota counter for a client is zeroed exactly when
the panel zeroes that client's usage. InboundService.ResetAllTraffics
resets only inbound counters, yet it cleared every MTProto client's
sidecar quota - on the master and, through its node propagation, on every
node - handing out a fresh quota while the panel still counted the old
usage. ResetAllClientTraffics for one inbound likewise cleared the quotas
of MTProto clients on every other inbound.
The inbound-level reset no longer touches sidecar quotas, and the
per-inbound client reset zeroes only the clients it reset.
Invariant: a client traffic reset zeroes every counter that enforces the
client's quota - the master's, each hosting node's, and the local MTProto
sidecar's. A node cuts a client on its own local counters, and the master
adopts that verdict when both judged the same limits (#4917).
Node counters: ResetClientTraffic tried the node once and dropped a
failure ("nothing replays a reset"); BulkResetTraffic,
ResetAllClientTraffics and ClientService.ResetAllTraffics never told the
node at all. The node kept its pre-reset usage, switched the client off
again on its next tick, and the master latched that - a client shown at
zero usage stayed disabled.
Every reset path now queues a node_pending_resets row per hosting node in
its own transaction. It is delivered right after commit (per-client
endpoint up to the push threshold, bulkResetTraffic above) and replayed by
the node sync ahead of its snapshot, and dropped only once the node
accepted it. While one is owed, the merge takes only that client's usage
from the node, not its enable or limits. Deliveries to a node are
serialized so a reset is not sent twice.
Sidecar quota: BulkResetTraffic, ClientService.ResetAllTraffics and
auto-renew zeroed the panel counters but not mtg's own quota counter, so
the sidecar kept refusing a renewed or reset MTProto client. They now
reset it too, scoped to the affected clients.
Invariant: a client whose stats row is switched off is served by no local
runtime of any inbound it is attached to. client_traffics is email-keyed,
and AddClientStat re-points its inbound_id at the last inbound attached,
yet the runtime push builder and the MTProto, TUIC and AmneziaWG desired-
instance builders looked the row up by inbound_id. On every other inbound
of a multi-inbound client the depletion filter saw nothing, so a depleted
client stayed served there whenever its settings entry still read enabled
- the state the stale settings writes left in existing databases.
All four now resolve the flag through trafficDisabledEmails, keyed by the
emails the inbound actually lists. GetXrayConfig already backfills sibling
rows by email (backfillClientStats) and is unchanged.
Invariant: saving an inbound's configuration never changes a client's
enable, expiry, quota or renewal state, nor the inbound's own traffic
counters. The inbound modal posts back the whole settings.clients list it
loaded when it opened, and UpdateInbound stored it, synced it into the
client records and wrote it into client_traffics. Any client renewed,
depleted or reset while the modal was open was reverted - a renewed client
came back disabled with its old expiry. The row itself was read before the
serialized tx and saved whole, so traffic the poll added in between was
rolled back and a depleted inbound could be re-enabled.
UpdateInbound now re-reads the row inside the writer and, for clients the
inbound already holds, keeps enable, expiryTime, totalGB, reset, resetDay,
resetWeekday and resetMax from it; those change through the client
endpoints. A master's node-sync push stays authoritative. Existing clients'
lifecycle fields are no longer editable through the inbound JSON editor or
/inbounds/update, which the API docs now state.
The README screenshots still showed the v3.2.5 layout. Retake every panel
page in light and dark on the current UI, and redo the annotated Telegram
bot setup shot for the new sidebar layout, marking the enable toggle too.
Invariant: a traffic reset leaves a quota-disabled client enabled
everywhere. ResetTrafficByEmail and BulkResetTraffic enabled the client
first and zeroed its counters afterwards. A traffic tick landing between
the two still saw the client depleted and switched it off again in
client_traffics, the record and settings. Update's direct record write
then set the record back to enabled and the reset zeroed the counters,
leaving the settings entry disabled: the client showed enabled with zero
usage but was dropped from the runtime. The periodic reset job goes through
ResetTrafficByEmail for every depleted client on its cycle, and a node push
inside Update widens the window to seconds.
Zero first, then enable: with the counters at zero the depletion predicate
no longer matches, so the tick has nothing to undo. UpdateInboundClient
re-adds the enabled user to the runtime itself.
Invariant: an operation on an inbound's clients writes back only what it
changed, onto the settings as committed when it writes. Every client op
(add, edit, delete, bulk adjust/detach/delete/set-enable) read the inbound
outside the serial traffic writer and then tx.Save'd the whole row inside
it. A traffic tick that committed in between - auto-renew re-enabling a
client, the delayed-start conversion, a node adoption - was overwritten
with the stale copy. A renewed neighbour ended up enable=false with its old
expiry in settings while client_traffics said enabled, so the next runtime
rebuild dropped a healthy client nobody had touched. The bulk ops then ran
a full SyncInbound from those stale settings, copying enable=false into the
client record too.
Each site now commits through commitInboundClientSettings: inside the
serialized tx it three-way merges the op's edit (read -> output, per client
and per field) onto the committed settings and updates only the settings
column, which also stops the stale up/down/enable inbound columns being
written back. advancePushedInbound now records what the per-client push
delivered rather than the merged settings, so the node's reconcile-skip
fingerprint cannot claim a renewal it never received.
Panels up to v2.x stored a client's tgId as a string, "" when unset. The
startup migration heals copies already in the database, but an exported
inbound imported into a current panel goes straight to AddInbound, whose
typed client parse failed with "cannot unmarshal string into Go struct
field .0.tgId of type int64", so every such import was refused.
AddInbound now runs the legacy normalizer the clients-table seeder already
used (moved from database to model so both share it) over the incoming
settings before parsing them. String numbers become integers and empty ones
are dropped, and the settings are stored in that shape. The same applies to
/inbounds/add callers still sending the old types.
The seeder change is a pure move; the PostgreSQL lane was not run (no
Docker on this host) and is left to CI.
Closes#6663
check_status ran `systemctl status x-ui` and grepped its Active line. That
command also prints the unit's latest journal lines, so it reads the journal
every time the menu is drawn, before most actions, and on hosts with months
of logs the script stalled for a long time before showing its options.
`systemctl show --property=SubState` returns the same "running" state from
the unit alone. The prefix is stripped by hand rather than with --value so it
still works on systemd older than 230.
No test harness covers x-ui.sh. To demonstrate on a host with a large journal:
time systemctl status x-ui >/dev/null
time systemctl show --property=SubState x-ui
Refs #6629
xray's WireGuard inbound credits a packet to the first peer whose allowedIPs
contain its source address, and routes replies by the same table. The panel
only rejected an allowedIPs entry that was string-equal to another client's,
so a pre-assigned address typed in interface notation (10.10.2.9/24, as other
WireGuard tools export it) was accepted and claimed the whole /24: other
clients' traffic and online IPs were credited to that one email, and replies
went to a peer with no endpoint ("no known endpoint for peer").
The collision check now compares masked ranges on every path that uses it:
add, edit, the cross-inbound recheck inside the write transaction, and
AmneziaWG. Auto-allocation skips any address inside a prefix another client
holds. A /0 default route still claims nothing, as before, because legacy
migrated peers carry one. Clients already saved with overlapping ranges keep
working as they do today until edited. The API docs describing the error are
updated, including the stale claim that cross-inbound duplicates are accepted.
Closes#6623
The bot's "individual links" and QR actions fetched the client's own
subscription over HTTP from the public URL it builds for display. With no
sub or web domain set that URL falls back to the machine's hostname, which
usually does not resolve (`lookup exhausted-reply: no such host`), and even
a resolvable name fails behind NAT, a firewall or a disabled sub server.
Both actions now ask InboundService.GetSubLinks, the in-process provider the
panel's links API already uses, with the host taken from that same URL so the
link addresses are unchanged. The now-unused pooled HTTP client goes too.
Closes#6597
The REALITY Min Client Ver hint promised that an empty field accepts every
client version. That is only true on Xray-core v26.9.8+, but the panel's
core switcher still installs v26.7.11 to v26.9.7, where an empty field falls
back to a built-in 26.3.27 minimum and silently diverts older and
third-party clients (Mihomo, sing-box) to the REALITY target. Operators on
those cores were told the field was not the cause.
The hint now names the version boundary in all 13 locales, matching the
docs site. Text-only: no test can reach it; the change is visible in the
inbound form's REALITY tooltip after `npm run build`.
Closes#6568
sponsors.json only had an end date, so a booked placement had to be
added to the file on the day it started. An optional `from` now hides
a sponsor (and its logo) until that instant; entries without it show
immediately as before.
resolvingBind.ParseEndpoint resolved a hostname and then built a
StdNetEndpoint itself. That matches StdNetBind, the default bind on
Linux, but on Windows the default is WinRingBind, whose Send refuses any
endpoint it did not parse ("endpoint type does not correspond with bind
type"). Every handshake initiation failed there, so no AmneziaWG tunnel,
inbound or outbound, could come up on the Windows builds. ParseEndpoint
now hands the resolved literal to the wrapped bind's own parser, which
returns the endpoint type that bind sends to; StdNetBind and pinnedBind
build the same StdNetEndpoint as before.
The resolvingBind tests now read endpoints through the Endpoint
interface instead of asserting StdNetEndpoint, which had pinned the bug.
The panel's SOCKS5 egress for AmneziaWG outbounds bound the fixed
127.0.0.1:64900, and every generated socks bridge dialed that constant.
64900 sits inside Windows' dynamic port range, where the OS can reserve
whole blocks (this host excludes 64885-64984), so on the Windows builds
release.yml ships the listener could stay down and every AmneziaWG
outbound with it. Listen now tries 64900 first and falls back to any
free loopback port; bridges, the outbound probe and the port-conflict
check use EgressPort(), the port actually held. Bridges are generated
apart from the listener, so BuildSocksBridge records the port it wrote
and the AmneziaWG job requests an Xray restart while the listener holds
a different one. Where 64900 is free nothing changes.
The job's restart request is two lines of wiring no test reaches; the
staleness it acts on is pinned by
TestBridgesStaleUntilRegeneratedForTheBoundPort.
Seven tabs, several holding one or two settings, made the page hard to
scan. The encrypted-links switch moves above the tabs under
auto-detection, the colour profile joins the banners under Appearance &
Theme, and Android per-app proxy joins Network & TUN Engine. The QR
modal's settings link no longer needs a happTab selector, and the three
orphaned tab-label keys are dropped from every locale.
The renewal block took a full-width column, pushing Traffic Reset onto
its own line. Each now gets a half-width column like the other fields,
with its follow-up inputs stacked under it.
FileKeySource rejected any key file whose mode had group or other bits,
but Windows has no such bits: Stat reports every writable file as 0666.
On the Windows builds release.yml ships, the key file therefore never
loaded, not even one written 0600, and only XUI_NODE_TOKEN_KEY could
supply a key. The mode check now applies off Windows only, the stance
the DB permission tests already take; there the file's NTFS ACL guards
it, and env-vars.mdx says so in all four locales.
The load test is split so the half that must hold everywhere, an
owner-only file loading, also runs on Windows, and the rejection half
asserts the exact error instead of any error.
Windows env var names are case-insensitive, so https_proxy and
HTTPS_PROXY resolve to the same variable there and updateProxyEnvVars
forwards it under both names. The helper only runs on Linux - startUpdate
refuses every other platform - so the test's case-sensitive expectations
only hold, and only matter, on Linux.
Every InitLogger call built a new lumberjack rotator and dropped the old
one without closing it, leaking a handle on 3xui.log per call, and
loggers still writing through an old rotator kept it alive. On Windows
the open handles block deleting the file, so
TestInitLoggerConcurrentWithLogging failed its t.TempDir cleanup there.
InitLogger now reuses the open rotator for the same path and closes it
only when the path changes, and the test closes the logger it opened.
Neither half is enough alone: with only one of them the test stays red
on Windows.
InitDB assigned the new pool over the old one without closing it. The
panel's own restore flows call CloseDB first, but any other re-init
leaked the replaced pool and its handle on the database file. On Windows
that handle blocks deleting the file, which is why the four GetApiToken
CLI tests failed their t.TempDir cleanup there: dbtest.InitDB opened the
store, then GetApiToken's own InitDB replaced it. InitDB now closes the
previous pool itself; sql.DB.Close is idempotent, so the restore flows
behave as before.
.gitattributes forced LF only for Go, shell, generated files, snapshots
and deploy YAML, so a Windows clone with core.autocrlf=true got CRLF
everywhere else. On that working copy `make format-check` flags 198
frontend files, `msw-worker-check` sees mockServiceWorker.js differ from
the installed copy, and TestAnalystContextNamesRealCIJobs and
TestReviewNamesRealCIJobsAndGates find no ci.yml job at all, while Linux
CI stays green. `* text=auto eol=lf` extends 5c5a5096 to every text
file. No committed blob changes: the index already holds LF everywhere,
and binary detection still leaves the 50 binary files alone.
An existing Windows clone applies it with a fresh checkout on a clean
tree: `git rm -rq --cached . && git reset -q --hard HEAD`.
The runtime.GOOS switch compiled the syslog branch into Windows builds,
where go-logging's syslog stub always returns an error. staticcheck
therefore reported SA4023 at logger.go:95 on every Windows lint run,
keeping `make lint-go` red on a clean main there while Linux CI never
saw it. console_windows.go and console_other.go now pick the backend at
build time, with each platform's behaviour unchanged.
No test can observe build-tag selection: `golangci-lint run` on Windows
goes from 1 issue to 0, and `GOOS=linux golangci-lint run
./internal/logger/...` stays clean.
* feat(tgbot): gate the bot behind three user levels
Every Telegram account that found the bot could run /help, /status and
/usage, and tap any client button it could forge: nothing separated an
account no admin had bound from a customer.
Each update now resolves to stranger, client or admin, and commands are
allowlisted per level so a command added later stays admin-only until it
is listed. A stranger may run /start and /id only, and /start answers with
the ChatID an admin needs to bind it; a stranger's callbacks are answered
and dropped. Client detection reads the same tgId lookup as
clientOwnedByTgUser, so the level gate and the ownership check agree.
The bot also ignores everything outside private chats: authorization keys
on the sender while wizard state keys on the chat, and the two are the
same identity only in a private chat.
* feat(tgbot): bind Telegram accounts through /start deep links
Linking a customer meant the customer sending /id and an admin copying
the ChatID into the client by hand, which does not scale past a few
customers and is easy to get wrong.
The admin client card now offers an invite link, t.me/<bot>?start=<subId>,
and the first account to open it is bound through the existing
SetClientTelegramUserID. A subId already grants the subscription, so
binding gives the holder nothing the token did not. A subscription that
spans several clients binds all of them, and is refused if any part
belongs to another account; re-opening your own link is idempotent.
Unknown and already-claimed tokens share one reply, so the link cannot
be used to probe for valid subIds.
* fix(tgbot): harden invite claims after review
Review of the access-level and binding change found five problems:
- Concurrent claims of one link all read the client as unbound, all bound
and all were told so, while only the last write held. Resolving and
binding now share one lock, and a bind that fails part-way through a
multi-client subscription undoes the bindings it already made.
- A subId has no minimum strength and the bot needs only its public
username, so /start was an unthrottled guessing oracle. Non-admin claim
attempts are capped at five per account per hour, the first refused one
notifies the admins, and the Subscription ID field now says it doubles
as the bot invite code.
- levelOf expanded every inbound's client JSON on every non-admin update.
It now reads the indexed tg_id column of the clients table.
- A button tapped in a group chat was dropped unanswered and kept
spinning, with nothing logged. It is answered now, and each ignored chat
is logged once.
- The subId was pasted raw into the t.me link, so '#' or '&' truncated it
and Telegram rejects anything outside A-Za-z0-9_-. The payload is now
base64url, and a subId too long for the 64-character limit is refused.
* fix(tgbot): answer group chats again and make the claim race test bite
ignoredChat dropped every non-private chat because wizard state was
keyed by chat while authorization keyed on the sender. #6604 on main
re-keyed that state by (chat, user) so admins can drive the bot from a
group, so after the merge the drop only took the whole bot away from
those admins, report keyboards sent to a group included. The level gate
already keys on the sender, so group chats need no special case.
TestConcurrentClaimsBindOnlyOneAccount passed with inviteClaimMu
removed: the first claimant took the pool's idle connection and bound
before the rest had opened theirs, so no two ever raced. It now holds
the inbound write the binds need until every claimant has resolved,
and fails without the lock ("6 accounts told they bound").
TestCommandAllowed restated the commandsByLevel map; TestGateCommand
drives the same allowlist through gateCommand. TestIgnoredChat goes
with the code it pinned.
* docs(tgbot): document access levels and invite links
The command table still said /help and /status answer anyone. An
account no admin has linked now reaches only /start and /id, and a
customer is linked through the client card's Invite Link, whose token
is the Subscription ID. Updated in en, fa, ru and zh.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* feat(inbound): excludeFromSub hides links without disabling
Add a per-inbound flag that omits subscription output while keeping the
inbound enabled for Xray, auth, and traffic accounting. Fixes#6435.
* fix(inbound): excludeFromSub review follow-ups
gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored
on node mirror, and exercise the legacy add-column migration path in tests.
* fix(sub): keep excluded inbounds' clients in the usage header
The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded
inbound's clients never reached seenEmails in the raw, Clash or JSON
renderer. A client that lives only on a hidden inbound (one client per
inbound sharing a subId) dropped out of the Subscription-Userinfo usage,
quota and expiry and out of the info-node state, while the inbound kept
serving it and counting its traffic.
The query returns every enabled inbound again; each renderer skips an
excluded inbound's links but still counts its clients, the same rule the
Clash renderer already applies to external links it cannot express.
---------
Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
Most tests opened a throwaway panel DB with database.InitDB, which runs the
full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under
-race because GORM's reflection-heavy migration is what the detector slows
most. internal/web/service does this in ~550 of its 830 tests, so the CI race
job spent ~10 of its ~14.6 minutes re-migrating empty databases.
internal/database/dbtest.InitDB migrates once per test process, then hands
each test its own copy of that file (~130ms under -race) and registers the
CloseDB cleanup. The copy then goes through InitDB like a panel restart, so
every test still starts from the state a fresh install has. Tests that reopen
an existing file, migrate a hand-built legacy DB or target Postgres keep
calling database.InitDB.
Locally under -race: internal/web/service 626s (last CI run) -> 114s,
internal/sub 246s -> 35s.
* feat(clients): preserve traffic counters in portable export/import
ExportAll now attaches client_traffics up/down (plus resetCount and
last-seen fields) on each portable payload, and ImportClients restores
them only for newly created emails so skipped/existing clients keep
their live counters. Fixes#5858.
* fix(clients): restore imported traffic only onto rows the import created
Review of the portable-traffic export/import (#5858) found four defects:
- An orphan's restored row was hand-built, dropping reset_weekday and
forcing enable=true; a row kept by a keepTraffic delete kept the old
client's limits. depletedClientsClause then matched a weekly-renewing
over-quota orphan and DelDepleted deleted it. Orphan rows now go
through AddClientStat, whose upsert refreshes config and keeps counters,
so the unused traffic.total field is dropped from the export.
- Created clients were inferred from Skipped emails, so a duplicate email
in the file left the created copy with zero counters. bulkCreate now
reports which payloads inserted a record, and only those are restored.
- Each client took its own serialized-writer commit: 2000 clients spent
3.66s instead of 0.52s. Counters now apply in batched transactions
(0.51s).
- importClients discarded needRestart when the late restore step failed
after clients were committed; it now flags and notifies first, as
create already does.
The /clients/export and /clients/import API docs now describe traffic.
* fix(groups): keep imported traffic out of group totals
Group totals keep a deleted client's usage (#5675), and the portable
import restores that same usage onto the re-created client. Export,
delete, re-import therefore counted it twice in ListGroups, and a fresh
panel showed the migrated usage as consumption of its groups.
Restored counters are usage from before the import, so the import now
shifts each group's baseline up by what it restored, in the same
transaction. A group total no longer moves at import time; only traffic
consumed afterwards counts. The baseline shift reuses the #5675 helper,
now signed.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
The release matrix (7 Linux cross-builds + a CGO Windows build) ran on every
PR and on every branch push, so a PR from a repo branch built everything
twice. Release binaries now build only on main (dev channel) and version
tags; any other branch can still be built via workflow_dispatch.
CodeQL keeps its push-to-main and weekly scans but no longer runs per PR.
The deploy smoke workflow fired on every Release completion only to skip
its jobs; deploy/test/smoke-noninteractive.sh stays for manual runs.
* feat(tgbot): add /broadcast to relay an admin message to all clients
Admins had no way to reach every client at once: notifications only
cover exhausted quotas, so an operator had to copy a message to each
client chat by hand. Add an admin-only /broadcast flow to the bot:
- /broadcast asks for a message; any message the admin sends — text,
rich text, photo, video, file, sticker or a whole album — becomes the
broadcast by reference (admin chat + message ids), and a preview
self-copy shows the admin exactly what recipients will get while
rejecting content Telegram cannot copy before the run starts.
- The draft references the original instead of parsing its content, so
copyMessage/copyMessages deliver everything 1:1 on behalf of the bot
with no forward header (the admin's identity stays private), no
caption length pitfalls, and future Telegram message types work
without new parsing.
- A media group arrives as separate updates; its ids are buffered with
a short debounce, sorted, and delivered as one copyMessages call so
recipients see the original album.
- Delivery runs in a background goroutine (common.GoRecover): sequential
sends with a small pause, 429 retry_after honored per recipient,
failures counted without stopping the run, progress edited into one
card at most every 25 sends or 3 seconds, a cancel button checked
between sends, and a final delivered/failed/skipped summary. The
summary is edited into the card (only sent separately if the card is
gone), so it is never duplicated.
- Recipients repeat the notifyExhausted walk: clients with a linked
tg_id, deduplicated, admins excluded — they already receive the
reports. The message content is never logged.
New i18n keys are added to all 13 locales.
* fix(tgbot): harden broadcast composition per review
- Key the composition per admin chat instead of one process-wide draft:
two admins can now compose at once without dropping each other's
drafts, and one admin's /broadcast no longer wipes another chat's
half-collected album.
- Bind each preview card to its own draft via a random token carried in
the confirm callback, so a stale Send tap is answered with an error
instead of delivering a newer, unapproved draft.
- Ignore non-admin senders while a chat composes: the awaiting state is
keyed by chat id, and in a group that chat is shared.
- Check the cancel flag inside the flood-control retry loop, so a 429
with a long retry_after no longer holds the single broadcast slot
after the admin cancelled.
- Scale the per-recipient pause by the copied batch size, so an album
keeps the same per-second ceiling as a single message.
- Trim the comment blocks that exceeded the two-line cap.
* fix(tgbot): reset broadcast state on stop and classify 403 as skipped
- Clear compositions and cancel the active run from StopBot, next to the
per-chat draft resets: an album debounce timer, a confirmable token or
a held runner slot must not outlive the receiver that created them.
- Sleep flood-control waits in 5 s slices and re-check cancel and bot
state between them, so a minutes-long retry_after no longer parks the
single-runner slot after the admin cancelled or the bot stopped.
- Count Telegram 403 (the chat never started the bot, or blocked it) as
skipped instead of failed, log it at debug rather than one warning per
recipient, and append one line to the summary naming the reason.
- Trim the remaining comment blocks over the two-line cap.
* fix(tgbot): count unreachable recipients in broadcast progress throttle
The progress card refresh was keyed on sent+failed, which a 403 does not
advance since unreachable chats were split out of the failure count. A
streak of unreachable recipients while that sum sat on a multiple of
broadcastProgressEvery (0 included, so from the very first recipient)
edited the card once per chat, doubling the request rate the send delay
is sized for and defeating the throttle. Count processed recipients.
* fix(tgbot): key broadcast compositions by admin, not chat
After #6604 moved conversation state to the admin (chatUser), the
broadcast draft map stayed keyed by chat. Two admins composing in one
group then shared a slot: the second admin's message dropped the first
admin's draft, whose Send tap answered "went wrong" while only the other
draft could go out - the same class #6604 fixed for the add-client
wizard. Drafts, album buffers and confirm tokens now live under the
admin who ran /broadcast.
The router now hands handleBroadcastInput only the admin whose own
/broadcast is awaiting input, so its sender re-check and the test that
fed it a non-admin message directly (an input no route can deliver)
are removed.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>