TestScreenArtReprobeWindow failed under -race -shuffle in CI: the probe
slot is process-wide, and a probe an earlier test started against its
own (now closing) server still held it when the next test asked for a
re-probe, so that request was dropped and the stale avatar never
re-probed. The leftover probe could also write its result over the
state the next test had just reset.
Probes now run in a WaitGroup that resetScreenArt waits on, so a reset
between mock servers always starts from an idle probe slot.
xray-core 26.10.31 (#7231) added an "s3" XDRIVE service that carries the
stream through S3-compatible storage: secrets are AccessKeyID,
SecretAccessKey and an optional SessionToken, an "s3" object names the
endpoint, region, bucket and pathStyle, and remoteFolder becomes the
object prefix.
The schema and the shared XDRIVE form offer the service with a field for
every S3 key, so an outbound or inbound keeps its storage on save. The
save path refused "s3" as an unknown service; it now runs the vendored
loader, whose ValidateS3Config would otherwise only fire when the
listener starts and stop every inbound. A node refuses a pushed S3
storage like a pushed template one, since its endpoint is any URL the
node would dial, including its own panel. Labels in all 14 locales;
docs updated in en/fa/ru/zh.
xray-core 26.10.31 (#7288) widened a TUN inbound's
autoSystemWfpBlockLeak: "dns" now also blocks DNS over TLS/QUIC (port
853), "windnsclient" covers only Windows' DNS Client service, and any
"tcp/<port>" or "udp/<port>" keeps that port inside the TUN. Anything
else, such as "tcp/0" or "icmp/1", stops the core at startup.
The form offers windnsclient and takes typed port values, the hint
explains them in all 14 locales, and the inbound and template save
paths run the core's own ExpandWfpBlockLeak so a value it refuses is
refused at save instead of taking every inbound down.
xray-core 26.10.31 (#7295) added xhttpSettings.h2Params, a Go net/http
HTTP2Config the XHTTP server applies to its HTTP/2 listener. The
inbound form parsed xhttpSettings through a Zod object that strips
unknown keys, so a value set through the JSON editor vanished on the
next save; it is now kept as an optional record.
h2Params tunes the server only. Share links already carry an allowlist
of XHTTP keys, but the JSON subscription copies the inbound's
xhttpSettings minus a denylist, which now drops h2Params too.
xray-core 26.10.29 (#7267) checks a scheduled geodata section when the
core starts: at most 100 assets, every file a .dat, a cron it can parse
and an @every interval of at least a minute. A refusal there stops the
core and every inbound with it, and the panel's own file-name pattern
still let any extension through.
The geodata editor now requires a .dat file name, and the template save
path applies the same checks the core does. The invalid-file hint names
the .dat requirement in all 14 locales.
xray-core 26.10.29 (#7249) narrowed the "private" addresses an
unencrypted vless/trojan outbound may reach to RFC 1918, loopback and
link-local, so 100.64.0.0/10 (CGNAT, Tailscale), 198.18.0.0/15 and the
other ranges of the old geoip:private list are now refused. The
embedded validator follows the new core, which made the template save
and outbound subscriptions refuse such an outbound even when the panel
still runs an older core that accepts it.
The legacy skip now also lets through, on a core from 26.7.11 up to
26.10.28, an outbound whose server address (flat, vnext or servers) is
an IP in that core's wider private set. Unknown versions stay strict.
Bump xtls/xray-core to a4a3dff5b20c (v26.10.31) and the three binary
pins in DockerInit.sh and release.yml in lockstep, so the in-process
conf.Build() validation and the child binary agree. The other Go
modules move to their latest patch/minor releases in the same go.mod.
26.10.31 moved ResolveLuaFile from common/platform to
common/platform/filesystem; childLuaScriptPath now calls it there. The
lookup order is unchanged, the extension check is now case-insensitive.
TestSubscriptionDecodesInboundSettingsOncePerRequest capped one request
at a fixed number of bytes, which the race detector, a Go upgrade or a
richer link format can move: the race job already broke 128 MiB on
correct code, and raising the cap only waits for the next drift.
The test now builds each subscription at n and 3n clients and asserts
the allocation grows by at most 5x. One decode per request grows about
3x (2.94 to 3.40 measured, under -race too); one decode per link grows
6.9 to 9.3x, so the bound tells the two apart whatever the absolute
sizes are.
Telegram fills a message's From with one of three fixed placeholder
users whenever it is posted on behalf of a chat, by an anonymous group
admin or a channel. The level gate and the invite claim took that
placeholder for the account acting, so it got a throttle window of its
own, and a claim bound the client to an id every anonymous admin shares.
Such a message is rated as a stranger whatever the placeholder id is
bound to, and its invite claim is refused before it is counted or bound.
The geofile browser indexes every category record and attribute key a
database holds, which cost the panel far more memory than the file's
bytes: a crafted file of a few megabytes full of empty categories or
attribute keys ran the panel out of memory. The file size cap bounded
nothing here.
The scan now counts records and attribute keys as it keeps them and
refuses a database past 65536 with "too large to browse", which the
cache remembers; the other layout is not retried for the same file.
Every failed login was published on the event bus, and the e-mail,
Telegram and Discord notifiers exempt login events from their cooldown
so that successful logins are never swallowed. An unauthenticated login
flood therefore filled each notifier's queue and crowded out every
other alert, a successful login, an Xray crash or a node going down.
UserLoginNotify now passes a failed attempt only for the first failure
of its source address in a minute, at most ten addresses per minute;
successful logins are always published.
GetXrayLogs read access.log with a default bufio.Scanner, whose token
limit is 64 KiB, and returned nothing at all when it stopped with
ErrTooLong. Xray logs an HTTP request's URL whole, so one proxied
request with a long URL blanked the Access Logs view for every client.
Lines are now read with a bounded reader: a line that fits is kept
whole, and a longer one keeps its first 64 KiB and its last 1 KiB,
where the route and email are, so it still shows as a correctly
attributed row.
claimInvite took the package-level inviteClaimMu and released it
without defer, inside a handler whose panics the bot recovers. A panic
while resolving or binding a token left the lock held for good, and
every later /start claim parked on a worker slot until the bot answered
nobody.
The lock is now held in its own function with a deferred unlock.
The Telegram login alert is a list of "label: value" lines, and the
username, whatever the visitor typed on the login form, was inserted
with its line breaks intact. Text after a break read as the panel's
own IP and time lines, letting a failed login forge the alert.
Control characters and Unicode line separators in the username become
spaces, in the failed and the successful alert alike.
Teardown and the per-peer diff built the list of IPv6 aliases to delete
from configuration alone, while adding an alias treated "already
exists" as success. An address the host already owned, ::1 on lo or its
own public /128, was therefore deleted when an inbound or peer that
named it went away.
The manager records the aliases whose add actually created them and
deletes only those; an address that was already there is left in place.
A client with an inbound link is not an orphan, but the node sweep's
mark was cleared only at the end of a node merge. An admin attach left
a stale mark, and ReapSyncOrphans deleted the client at its next
detach, traffic and IPs included, without the grace window.
Inserting a link now clears the mark of the clients it links, and the
reaper rereads its candidates inside its writer, so an attach that
commits after the list was read is not reaped.
One mtg-multi process serves every client of an MTProto inbound from a
single TOML file and refuses the whole file over one value it cannot
parse. InstanceFromInbound already guarded the ad tag for that reason
but copied secret, quota and expiry as stored, so a malformed secret, a
year-10000 expiry or a near-MaxInt64 quota on one client took the
sidecar down for everyone on the inbound.
A client whose secret mtg cannot parse is left out with a warning, and
quota and expiry are capped at what mtg accepts.
The running core must only see clients the panel has enabled, and
UpdateInbound and SetInboundEnable already build their payload through
buildInboundForLocalRuntime, which strips disabled ones. AddInbound used
it only for MTProto and TUIC and hot-added the stored row for every
other protocol, so an imported or pushed inbound re-armed its disabled
clients until the next restart.
AddInbound now builds the runtime payload the same way for every
protocol.
disconnectClientTemporarily removed an over-limit client from the core
and re-added the snapshot the scan had loaded 100 ms later, so a
disable or detach committed in between was undone: the traffic job's
own RemoveUser had already found nothing, and the disabled account
stayed live in Xray until the next restart.
The re-add now rereads the client and its inbound and skips one that
is disabled, expired, over quota or no longer attached.
Every Discord alert field went through cleanField, which only trimmed
and truncated, so a login username, a node's error text, a remark or an
email reached Discord as live Markdown: masked links, bold, headings,
mentions. A failed login with a crafted username could post a working
phishing link in the admins' channel.
Foreign text is now Markdown-escaped in every field and command reply,
and values shown in code spans can no longer close their span.
buildBalancerConfig restarted the free tag-suffix search at 2 for every
member, so member k formatted and looked up k tags and n members of one
protocol cost n²/2 lookups; a balancer with many same-protocol members
held a subscription request for minutes.
The search now resumes per base tag where the previous member stopped,
producing the same tags in linear time.
net/smtp buffers a server reply of any length, so a mail server, or
anyone on the path before STARTTLS, could send a greeting or EHLO reply
that never ends and grow the panel's heap until the 30 s deadline, on
every notification.
Each session now reads at most 1 MiB from the server, below TLS, and
fails with a clear error past it.
getPublicIP read a third-party IP echo service's body whole and cached
whatever came back as the panel's address, so one misbehaving service
could make the panel inflate an endless response at startup, and an
error page became the address shown in every status sample.
The read is capped at 64 bytes and only a parsable IP is accepted;
anything else counts as unavailable. The GitHub release lookups are
capped as well.
The socket-front lookup filtered and extracted settings.fallbacks with
SQLite's json_valid, JSON_TYPE and json_extract, which PostgreSQL does
not have, so on PostgreSQL the statement failed and a legacy @socket
child never got its front's address and security in its links.
The lookup now selects the inbounds whose settings mention fallbacks and
parses them in Go, which works on both databases and still skips an
inbound whose settings do not decode.
decodeCappedValues is documented to count JSON values, but it also
counted object keys and closing brackets, so a node's GUID-keyed online
and IP trees reached the cap at roughly half the stated number of
values, and the master then refused the whole report rather than the
excess.
Keys and closing brackets no longer count; every value, nested ones
included, still does.
The migration backfills sync_orphaned_by = 0 for every client a node
merge had already soft-orphaned, and the gate refused a detached record
unless its mark named the reporting node. A client inside its orphan
grace window at upgrade time was therefore refused when its node
reported it again, and ReapSyncOrphans then deleted it on the master
while the node kept serving it.
A mark that names no node predates the column and may be taken back by
any node, as before.
The node client gate loaded the records it judges with LOWER(email) IN
the lowered reported emails, but SQLite's LOWER folds ASCII only. For a
stored email such as Иван no record was found, so the node's claim was
treated as a new client and passed untouched, rewriting a client that
lives only on the master's inbounds.
The lookup matches the lowered or the stored spellings, as the shared
email checks already do.
Guarding the external subscription fetch against a provider's redirects
also refused the URL the operator configured when it names a LAN or
loopback address, and the guarded transport dropped the host's proxy
setting, so a panel merging another panel on its own network, or one
reaching the Internet only through a proxy, silently lost those links.
A provider listing more than 1000 links was refused whole as well.
A URL that names a private address itself is now fetched, while a
hostname still is not, since the provider controls what its DNS answers;
public providers stay held to public addresses through every redirect.
A fetch the host's proxy setting covers goes through that proxy, and a
long list is cut to its first 1000 links instead of refused.
Subscription links escaped the VLESS id with url.QueryEscape, while the
panel builds its own link and QR through the browser's URL parser,
which escapes userinfo with the WHATWG userinfo set. A non-UUID id such
as team+1 was therefore served as team%2B1 by the subscription and as
team+1 in the QR, and a client that does not percent-decode userinfo
authenticated with a different id depending on where it came from.
The id is now escaped with exactly that set, which still keeps a line
break or an "@" from splitting the link.
The client gate refused a node's entry for an email the master serves
only on its own or another node's inbounds, and so also dropped the
node's usage for it; on top of that, a refused or rewritten entry made
the master mark the node dirty and push its own settings back, which
deleted the node's local client of that email. A master → node → node
chain whose panels each serve the same client therefore stopped adding
the nodes' usage to the master's totals.
Such an email is now usage-only: the node's usage still adds to the
master's row, while its enable, expiry and record values do not. A
claim rewrite no longer forces a re-push; the stored, filtered settings
are what the reconcile fingerprint tracks.
TestSubscriptionDecodesInboundSettingsOncePerRequest capped a request
at 128 MiB, which the race job exceeds on correct code (up to 170 MiB)
because the detector inflates allocations. One decode per link costs
489 to 750 MiB without it, so a 256 MiB bound still tells the two apart.
The per-peer IPv6 freedom outbounds the panel injects for AmneziaWG
clients were built with empty settings and their routing rule sits
ahead of the template's private-range block, so a peer's IPv6 traffic
skipped both layers and could reach fc00::/7 hosts on the panel's
network.
Each per-peer outbound now carries the same private-range finalRules
block every other freedom outbound gets.
PeerIndex names the peer a source address came from, but for two peers
holding the same prefix, which only a default route can be after the
overlap check, it kept the first while amneziawg-go's allowed-IPs table
hands the prefix to the peer configured last. Traffic was then billed
to a different client than the one the device accepted it from.
An equal-length match now goes to the later peer, as in the device.
Listing a Telegram account's clients ran CheckIsEnabledByEmail and
describeAttachedInbounds for every client, and both loaded and parsed
the client's entire inbound, so one message cost the number of clients
times the size of their inbound's settings.
CheckIsEnabledByEmail now reads the client's own record first, keeping
the inbound parse only for legacy clients never normalized into the
clients table, and describeAttachedInbounds takes every remark in one
query without loading any settings.
genTuicLink and genAmneziaWGLink, and their Clash and JSON siblings,
parsed the inbound's whole settings, every client included, for each
link they built, bypassing the per-request caches the other generators
use. Building the links of n clients of one inbound, as the export of
all links does, cost n full parses of an n-client document.
Typed per-request caches next to settingsByInbound now hold the decoded
TUIC instance, indexed by email, and the AmneziaWG server block, and
every generator reads through them.
projectThroughFallbackMaster found the front of each unix-socket child
by scanning every inbound's settings JSON, once per child, so a
subscription cost the number of socket inbounds times the size of all
settings in the table.
A request now collects every host's socket fronts in one statement,
the lowest-id front naming a socket on that host still wins, and an
inbound with unreadable settings no longer fails the lookup.
The logo cache was keyed by whatever names the sponsors list named and
never pruned, so a list naming thousands of logos made the public logo
route pin hundreds of megabytes, and every hourly refresh with new
names added more.
Only the first 32 distinct logo names of the active list are kept,
surplus sponsors render with their initial as a bad logo name already
does, and each store drops cached logos no longer on the list.
EnforceHwidForSubID read the subscription's device limit and, finding
none, recorded the device without looking again. A fetch racing an
operator who had just set a limit could therefore register its device
right after the limit was written and trimmed, leaving more devices
registered than the limit and serving all of them.
The tracking now re-reads the limit and writes the row in one
transaction, which serializes with the operator's, and a limit that
has appeared sends the request through the limited gate instead.
Every bot message is sent HTML-parsed while the intended markup lives
in the translation templates, yet the values filled into them were
escaped only at some call sites. Remarks and e-mails, which a node
chooses for the inbounds it reports, reached the scheduled report and
the client info unescaped and turned into markup, and the reports that
escaped their whole text instead showed the templates' own tags.
I18nBot now escapes every parameter value, the call sites drop their
own escaping and the whole-report escapes, the two values concatenated
outside a template are escaped where they are written, and callback
answers, which are plain text, are unescaped again.
UpdateInboundClient carried the stored forwardedPorts forward whenever
the incoming value was empty, and Client.ForwardedPorts was a plain
omitempty string, so clearing the field in the client editor, as its
hint says to, reported success while the settings kept the old ports
and the host kept relaying them to the client's device.
The field is now a pointer, as KeepAlive is: nil (not sent) keeps the
stored spec and "" withdraws it. The client record follows the live
spec on every update, and a one-time seeder fills records that still
hold their creation value from the inbound settings, so the editor no
longer posts an empty field that would drop forwards added later.
Create, BulkCreate, Attach and BulkAttach built one marshalled copy of
the client per entry of the request's inboundIds and held all of them
until the fan-out returned, with repeated ids never collapsed. An id
costs two bytes of request body, so a large client named on the same
inbound a few thousand times needed gigabytes and the panel died.
Every fan-out entry point now collapses the id list first, keeping the
order of first occurrence so credentials are still minted on the first
inbound.
effectiveHwidLimitForSubID took the limit over enabled clients only. A
client switched off for expiry, quota or by hand therefore had no limit
at all, while the subscription body is still served for disabled
clients, so any number of devices registered and stayed past the
renewal.
The limit now follows the subId whatever the enable flag, and the gate,
the slot status and the trim keep sharing that one query.
The checks that decide whether a client_traffics row is still used by
another inbound, whether an email is already taken under another subId,
and which depleted rows a scoped delete may drop compared a Go-lowered
email with LOWER(clients.email), which SQLite folds for ASCII only. An
email such as Иван was never seen as attached elsewhere, so the node
sweep deleted the usage row of a client the master's own inbound still
served and the client outlived its expiry and quota.
Each check now matches the lowered or the stored spelling and keys the
result in Go, so ASCII behaviour is unchanged.
scanIndex merged each further record of an already-seen category with
mergeAttributes, which rebuilt a map and re-sorted every attribute the
category had so far. A category with A attributes split over N records
cost A*N, about an hour and a half for a 5 MB file, while the scan lock
was held and every geodata route waited on it.
Each category now collects its attributes in one set during the walk,
and the sorted lists are built once at the end.
Every traffic reset re-enabled its clients unconditionally, in the
client record and in the traffic row a master treats as the client's
state. A master forwards an inbound's scheduled reset and "reset all"
to its nodes through the per-client reset calls, so a client disabled
by hand on a node inbound came back on at the next scheduled reset.
Each reset now decides, before it zeroes the counters, which of its
clients the quota switched off, counting cross-panel usage, and
re-enables only those; a client disabled below its quota keeps its
record and traffic row off. The reset API docs say so.
autoRenewClients re-enabled every renewing client whose traffic row was
off, as if only the panel's quota and expiry check cleared that flag.
The operator's disable routes (the client editor and bulk disable)
clear it too, so a client disabled by hand came back on with a fresh
period and zeroed counters as soon as its Auto Renew period ended.
A renewal now restores only a client the lifecycle job switched off:
its quota is spent, counting cross-panel usage, or its record was
stamped at or after its deadline, which the job does and a disable of a
live client cannot.
Attach and BulkAttach copied a client record's forwardedPorts onto the
target inbound. A client a node reports keeps the forwards the node
chose, so attaching it to one of the master's AmneziaWG inbounds opened
listeners of the node's choosing on the master, among them the
loopback relay port a later AmneziaWG or TUIC inbound derives from its
id; that inbound's traffic then went to the forward and Xray failed to
bind.
A forward is set per inbound and is no longer carried by an attach, and
creating an AmneziaWG or TUIC inbound is refused while a peer forward
already holds its relay port.
The one-time ClientsTable migration builds client records from each
inbound's settings with a plain Create, and gorm's default:true on
ClientRecord.Enable drops an explicit false from that insert. Every
client an operator had disabled before v3.1.0 was therefore stored
enabled on an in-place upgrade or a restored backup, and Xray, which now
reads that column, let the customer back in.
The seeder restates enable=false after Create, as the service paths do
since #6478, and a legacy client whose settings carry no switch stays
enabled, as releases before 3.1.0 served it.
A subscription's size is the product of its clients and inbounds with
each one's remark and endpoints, and nothing bounded that product: data
adopted from a node could make one unauthenticated request render
gigabytes and run the panel out of memory.
Each request now carries an 8 MiB budget that the raw, JSON and Clash
outputs charge as they materialize a client's links, configs or
proxies. Rendering stops once it is spent, with one warning, and
anything below the budget is unchanged.
A subscription renders an inbound's stream and remark once per client
and per external proxy, and every client holding its subId, all values
a node chooses: an inbound with a megabyte stream and thousands of
external proxies, or many clients sharing one subId, made a single
unauthenticated request cost gigabytes of memory and minutes of CPU.
A reported inbound whose streamSettings exceeds 64 KiB, sniffing 16 KiB
or remark 1 KiB, or that lists more than 64 external proxies, is no
longer taken in (an existing one keeps its stored values), and a node
can no longer give a client a subId another client already holds,
including another one in the same snapshot.