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.
A node-reported host group is stored once per address and per adopted
inbound, so the rows and bytes a merge wrote were the product of three
values the node chooses, unbounded by the response size cap: a small
hosts/list answer could fill the master's disk.
A group whose serialized form exceeds 16 KiB is no longer adopted, and
a node's inbounds hold at most 2000 host rows, counted over what they
already hold, so repeated merges do not add more; the merge logs how
many groups it left out.
The engine-wide gzip middleware gave every in-flight request its own
compressor of about 1 MiB, and the public /assets bundles let an
anonymous client keep a handler blocked in Write until WriteTimeout, so
a thousand stalled downloads held a gigabyte. The listener itself took
any number of connections, each holding buffers and a goroutine before
any login.
The bundles are now served from a gzip copy made once per file, with
the assets prefix left out of the per-request middleware, and the panel
listener closes a new connection once 2048 are open or its source (an
IPv4 address or IPv6 /64) holds 256; a loopback reverse proxy counts
only toward the total.
A subId is the only selector of a subscription's inbounds and external
links, so a blank one matched every client stored without a subId, as
clients embedded through inbounds/add or inbounds/import are. The
Telegram bot authorised a client by email and then looked its links up
by that blank subId, handing a bound user the working links of every
other subId-less client.
A blank subId now resolves to nothing in the subscription service, and
the bot builds a subId-less client's own links by email, as the panel's
client page does.
The subscription routes run the HWID gate before they look up the
subId, and the gate read "no device limit" and "no such subscription"
alike, so a request naming any path segment with an X-HWID header was
answered 404 yet stored that segment, up to about 1 MiB, in
client_hwids for good. Anyone reaching the subscription port could fill
the disk until the panel could no longer save anything.
An unlimited subscription is tracked only while an enabled client owns
its subId, and a one-time seeder drops the rows already stored for
subIds no client owns.
SetClientTelegramUserID patched tgId in the settings of only the
inbound the client's traffic row points at. Sibling inbounds kept the
old value, and the next sync of any of them wrote it back into the
client record, so removing a Telegram user from a multi-inbound client
silently re-bound that user to it.
The write now goes through applyClientFieldByEmail, as the other
by-email setters do, so every attached inbound, on the master or a
node, receives the new tgId.
The set that kept a refused node claim's warning to one line was keyed
by the reported email and never forgot one. Some refused emails, such
as a padded one or a new client holding a foreign subId, match no row
on the master, so a node rotating them grew the set without bound; a
snapshot made only of padded entries also skipped the warning.
Each node now keeps just the emails its latest snapshot was refused,
both return paths log, and the logged email is cut to 64 characters.
genHysteriaLink remembered which salamander settings it had warned
about in a set keyed by the setting names themselves, which a node
chooses with no bound on length or number and the set never forgot,
and it logged the names verbatim into the in-memory log buffer. A node
renaming a setting on every poll grew the master's memory by twice the
name's size per subscription fetch until the process ran out.
The set is keyed by inbound id and holds a digest of the names, the
warning repeats only when that set changes, and it logs at most eight
names cut to 32 characters.
The LDAP sync enabled every disabled client whose directory flag was
true, without asking why it was off. A client the traffic job had just
disabled for a passed expiry or a spent quota therefore came back on
every sync and connected again for the seconds until the next traffic
pass, for as long as the directory listed it.
The sync's enable step now drops the clients the panel's own depletion
rule keeps off, and enables nobody in a run whose lookup fails. A
client the operator renews is enabled at the next sync.
ValidateOutboundConfig, the save-time gate shared by template saves,
API saves and outbound subscription refreshes, relied on conf.Build,
which accepts a downloadSettings block with no address or on another
transport. The core's XHTTP dialer dereferences that address and
asserts that transport on every dial, so an imported link or a
subscription carrying such a block panicked Xray, and every user's
connections with it, whenever traffic was routed to that outbound.
The built sender settings are now checked for both, and the outbound
is refused with a message that names the missing part.
The IP-limit job treated every address on the core's online list as one
its sender proved to own and wrote the oldest over-limit ones for the
3x-ipl jail. For Shadowsocks over UDP and for mKCP the core lists a
datagram's source field with no exchange behind it, so a client with an
IP limit could forge sources and get any address firewalled.
A client attached to an enabled inbound that accepts such datagrams,
on the master or on a node, is still counted and disconnected, but its
old addresses are no longer written for fail2ban; a warning says why.
The node sync's sweep of an inbound the node stopped reporting deleted
the inbound but left its host rows, and the depleted-clients delete
left its sub-balancer references. SQLite hands a freed inbound id out
again once the table empties, so a node's host overrides could bind to
a new inbound it never hosted and point that inbound's subscribers at
an address, port and TLS settings the node chose.
delInbound's cleanup moves into deleteInboundRow (detach clients,
delete the inbound, its host rows and its sub-balancer references),
which the sweep and the depleted-clients delete now call as well.
GetXrayLogs held one entry for every line of Xray's access log and cut
the list to the requested count only at EOF, so the panel's memory grew
with the file. Xray writes a line for every refused request, which puts
the file's size in the hands of anyone who can reach an inbound port,
and the periodic prune does not bound it between runs. A negative count
also panicked on the final slice.
Entries beyond the newest count are dropped while scanning, and count
is clamped to 1..10000 with 100 as the fallback, as GetAmneziaWGLogs
already does.
When every client of a Shadowsocks-2022 inbound was disabled the panel
handed the core an empty client list, from which xray-core builds a
single-user server keyed by the inbound's server key alone. That key is
the first half of every link the panel ever issued, so a client cut off
for quota, expiry or by the operator connected again with it.
An inbound left without clients now gets a placeholder user whose key
is random and held only in memory, in the config and in the running
core alike. The core stays multi-user, and disabling the last real
client takes the same remove-and-restart path as any other client, so
its live sessions end as well.
UpdateInbound applies a master's push to the node's running core as the
difference between the stored and the pushed user sets, but both sets
dropped clients whose traffic row is off, and the same transaction had
already stored the pushed flags on those rows. A client the push
disabled therefore vanished from both sets, no RemoveUser was sent and
the node's Xray kept serving it.
The running set is now taken from the stored row's own enable flags,
so the client the push switches off is removed from every handler,
including a second inbound sharing the same traffic row.
projectThroughFallbackMaster found the front of a unix-socket child by
scanning every inbound's settings.fallbacks for that socket. A node's
inbound settings are mirrored from the node, so a node could name any
socket and have its own port, security and external proxies substituted
into the subscription links of another host's child.
A socket exists only on the host that binds it, so the lookup, and the
same scan the inbounds page uses for share links, now considers only
inbounds hosted where the child runs.
relayUDP started a goroutine for every domain-addressed datagram and
each lookup opened its own netstack UDP endpoint, so the lookups in
flight were bounded only by the client's send rate: one proxy user could
grow the panel by gigabytes and exhaust the outbound stack's ports,
stalling every user of that outbound.
One association may now have 32 datagrams waiting on DNS and drops the
rest, a cached name is delivered from the reader loop without a lookup,
and resolveTunnelVia takes one of 1024 process-wide slots before it
opens an endpoint, which also covers the TCP CONNECT path.
stackTun.WriteNotify runs on whichever goroutine made the stack emit a
packet; for echo replies and RSTs that is amneziawg-go's per-peer
receiver. It waited without bound for room in the outbound queue, whose
reader waits on that same receiver while the device rebinds, so a peer
that flooded the stack while nothing drained the queue deadlocked the
next reconfiguration of the interface.
The handoff now tries once, then waits at most 5 ms and drops the
packet, as channel.Endpoint already does when its own queue is full.
The embedded interface's TCP forwarder admits 65535 connections in
flight for all peers together, and a forwarded request holds its slot
until the handshake ends or gVisor gives up on it. One client sending
bare SYNs from fresh source ports therefore took every slot and left
the other clients' connections unanswered. The UDP relay likewise
opened a SOCKS5 association and a host UDP socket for every fresh
tunnel source port with no per-peer bound.
The forwarder now names the peer on the SYN, refuses a source no peer
owns and caps one peer at 4096 connections; the UDP relay caps one peer
at 1024 sessions and drops the datagrams that would need more.
joinHostPort wrote an endpoint address into the authority of every URL
share link unencoded, and genVlessLink alone skipped encodeUserinfo for
the client id. Both can come from a node's sync (an externalProxy dest,
an adopted host row, the client id), the finished link is never
re-parsed, and the raw subscription is one link per line: a line break
in either added lines of the node's choosing to the master's
subscription, and an id carrying "@" moved the client's VLESS links on
other inbounds to another server.
Whitespace, control characters and URI delimiters in the host are now
percent-encoded, which leaves host names, IP literals and zone ids
unchanged, and the VLESS id is encoded as userinfo like every other
protocol's credential.
The tunnel overlap guard treated every /0 allowedIPs entry as claiming
no address, while the entry was stored with its host bits and the
consumers read them back: the AmneziaWG port forwarder resolves a peer's
target from them and the device routes the /0 by longest prefix. An
entry such as 10.8.1.2/0 therefore passed next to another client's
10.8.1.2/32, and forwarded ports reached that client's device.
A written entry now counts with its whole range, so a /0 collides with
every held address of its family. On the stored side only a literal
0.0.0.0/0 or ::/0 default route keeps nobody out, and a /0 with host
bits claims that host. Editing a legacy peer that echoes its stored
default route still works.
The groups page lists clients.group_name as stored, while every group
action trims the name it is given. A name with surrounding white space,
saved by a node snapshot or by a client edit, was listed as a separate
group, and deleting, renaming or resetting that row acted on the real
group of the trimmed name instead.
Group names are trimmed wherever a client record is built or updated,
and a one-time seeder folds stored names onto their trimmed form.
DelDepleted turned the emails of depleted usage rows into client
identities through BulkDelete, which trims each email for request
bodies. A depleted row keyed by a padded email, which a node's stats
could store and which belongs to no client, therefore resolved to the
healthy client it resembled and deleted it. Rows whose key carries
surrounding white space name no client and are skipped.
A node snapshot was merged into the master's client records by email, and
the only ownership check filtered one derived copy of it: the node's
settings were still stored and re-synced later, its stats still merged,
and a record linked to the master as well as the node took every value
the node reported, including credentials rendered into the master's own
Xray config.
Each snapshot now passes one gate before anything is written. A record
also linked to the master or another node keeps the master's values
apart from per-link fields and the lifecycle the merge already settles;
a record linked only elsewhere, a detached record, a padded or
case-variant email and a new client holding another client's subId are
dropped from the stored settings as well. A soft-orphaned record can be
taken back only by the node whose merge orphaned it, which the new
clients.sync_orphaned_by column records. A node tunnel no longer owns
the peer keys of a client a master-local tunnel also serves.
A node-sync token, or a master enrolled with the mTLS certificate, may
push inbounds, and the node stored them as already validated. An
inbound is effectively config for a core running as root, so a push
could serve the host's files (hysteria file masquerade), relay to its
own services or the core API (masquerade proxy, fallback dest, REALITY
target), write files (masterKeyLog), use local or templated XDRIVE
storage, fetch URLs (ECH, realm masks), or run tun/http/mixed inbounds.
A push now refuses those fields unless the node already stores the same
value, which keeps rows configured by the node's own admin syncing:
relay targets must be public or another of this node's inbounds, and
only node-eligible protocols are accepted. Every caller is refused env:
references, which the core expands from the panel's environment, and
the inbound listen address is JSON-encoded into the config instead of
quoted by hand.
Every UDP destination a proxy user reached through the AmneziaWG egress
opened a session with a goroutine, a port and a buffer, per association,
and a Mux frame opens a new association, so one user could hold sessions
without limit. All egress UDP sessions now share one budget of 4096;
when it is spent the longest-idle session is closed to make room.
A forwarded port accepts unauthenticated traffic, and every TCP
connection and every new UDP source got its own relay with a goroutine
and a buffer, with no limit, so anyone reaching the port could exhaust
the panel's memory. Live TCP relays and UDP sessions are now capped at
2048 each across all forwards; past that a connection is closed and a
datagram from a new source is dropped, while open flows keep going.
Removing or re-keying a client reconfigured the device in place but left
its TCP and UDP flows standing, and withdrawing a port forward stopped
only new connections. The next client given the same tunnel address
inherited those flows: it could read what outside hosts sent to the
former client and write back into those connections.
After an in-place reconfigure, every stack endpoint and UDP relay
session pinned to an address whose peer was removed or changed is
aborted, and closing a port forward cuts the connections it is relaying.
A TCP port forward's accept loop returned on any accept error, leaving
the socket bound and the forward registered as open, so nothing served
the port again until a panel restart; briefly pushing the panel to its
open-file limit was enough. Forward and egress accept loops now log,
wait 100 ms (or until closed) and keep accepting.
The TCP forwarder handler read the tunnel client's address from the
accepted connection's RemoteAddr, which asks the live endpoint and is
nil once the peer's RST has been processed. A client sending SYN, ACK
and RST through its own tunnel could hit that window and crash the panel
on a goroutine with no recover. The forwarder now hands the handler the
source from the request's endpoint ID, as it already did for the
destination.
Anyone reaching a TUIC port could start QUIC handshakes without ever
reading a reply, each holding state and goroutines, and an unauthenticated
connection got a handler goroutine per stream it opened. Both grew until
the panel ran out of memory.
The listener now counts connections from their first packet until they
authenticate or close: past 64 they must answer a stateless Retry, and
past 1024 in total or 32 per verified source (IPv4 address or IPv6 /64)
they are refused. Before authenticating, a connection may start at most
16 handlers per stream kind; further streams are reset.
The UDP association cap applied per QUIC connection, and nothing limited
how many connections one credential could authenticate, so a single
client could hold enough UDP relays (each a host port) to exhaust the
host's ephemeral range. A client now gets at most 32 connections and 512
open UDP relays across all of them; past that a connection is refused at
authentication and a relay is not opened.
Dissociating a UDP association closed its SOCKS session, but the
response relay could be blocked opening a stream on credit the client
never grants, and only the connection's end released it. A client could
leave one such goroutine per Packet/Dissociate round, past the cap on
live associations. Each relay now runs under a context the association's
removal cancels, whichever path removes it.
max_idle_time and authentication_timeout are second counts multiplied
into a Duration with no bound, so a stored value past the int64 range
wrapped negative and the QUIC listener panicked on the first
ClientHello, taking the panel down at every start until the row was
fixed. Saves now refuse more than a day, and a stored row past it fails
its own listener start instead.
Every handshake initiation builds Jc junk packets of Jmin..Jmax bytes
and the I1-I5 signature packets, and the limits allowed the full uint32
range and lengths of any size, so one connection made the embedded
device allocate without bound. The MTU reaches the device as an int32
and a wider value made it panic. Both ended the whole panel at the next
connection, after every restart.
Jc is now at most 128, Jmin/Jmax and each signature packet at most one
65535-byte datagram, and the MTU at most 65535. Saves refuse more, the
device setup refuses rows stored before, and the stored MTU is capped
where it is read. The form schemas carry the same limits.
Per-client counters are stored up to TrafficMax, which a node can reach
by reporting huge usage, and the group totals, group baselines and the
per-client reset lookup added two such counters inside SQL, overflowing
it: every group listing and reset failed, and no client on that reset
cycle was reset. The counters are now read separately and added in Go,
saturating at TrafficMax.
allocateWireguardAddress walked the scope one address at a time and
tested each against every wide claim, so a claim covering an IPv6 /64
(a node can report such allowedIPs) turned the next address-less client
add into a 2^64-step loop that also held every later client change on
that inbound. The scan now jumps past a wide claim in one step, so it is
bounded by the number of claims.
An interval renewal turned the client's reset days into milliseconds
with no bound, so a value that wraps int64 (a node reports it unchecked)
sent the catch-up loop stepping through time for seconds per client
while the traffic writer held the database. Intervals above 36500 days
no longer renew, and every client write refuses them.
gregorianToJalali walked the calendar one month at a time from a 2024
reference, so an expiry near the int64 limit (a node reports expiries
unchecked) cost seconds of CPU on every subscription fetch that renders
{{JALALI_EXPIRE_DATE}}. Jalali leap years repeat every 33 years, so
whole 33-year blocks are skipped before the walk.
pushClientTraffics decoded the pushed list whole, and each "{}" became a
full traffic row, so a body under the size cap could cost the panel
close to a gigabyte. The list is now decoded entry by entry and refused
past 262144 entries. The capped decoder the node runtime already used
moves to json_util so both share it.
Deleting a client by an email no record holds fell back to a settings
LIKE '%email%' over every inbound with the raw route parameter, which
costs SQLite the settings length times the pattern length while it holds
the database; a long near-miss email froze the panel for minutes, and
wildcards were not escaped. The pre-filter now uses the first 64 bytes
of the email, escaped; the exact client match after it still decides.
A node's online tree and both client-IP reports were stored as sent:
they could name any client of the master or another node, under any
panel's GUID, dated into the future, and in any number. That marked
master clients online, fed source addresses for clients the node does
not serve into the IP limit and its fail2ban log, and planted rows the
retention sweep never removed.
Each report is now cut to the clients linked to the node's inbounds and
to the node's own key and its sub-nodes' origins (never this panel's or
another node's GUID); timestamps are capped at now, and one client's
list from a node keeps at most its 256 newest entries.
After a sync tick disabled clients, the master pushed every affected
node inbound with its own 4 s timeout, a few at a time, and the job
served no other node until all were done; a hanging node could stall it
for as long as it had inbounds to report. Pushes are now grouped by node
and each node shares one budget, still a few inbounds at a time; what
does not make it stays for the reconcile, the node being dirty already.
A per-client reset the node never accepted stopped the delivery loop:
resets queued before it were re-sent on every sync, each time wiping the
usage made since, and those after it were never sent. Each reset now
settles by itself, a refused one moves to the back of the queue, and the
loop goes on until the context is spent.
A node also delivered its master's reset to its own sub-nodes inline, so
one silent sub-node held the master's request until it timed out and was
replayed; the inline attempt now waits at most a second and the rest
finishes, or stays queued, in the background.
The node traffic sync restarted the master's own Xray with force after
any tick that disabled clients, so a node reporting a fresh over-quota
client every few seconds dropped every local user's connections each
time, though nodes serve none of them. Use the non-forced restart the
local traffic job uses: an unchanged local config keeps the core, and a
change still restarts when restartXrayOnClientDisable asks for it.
Reaching RestartXray needs a running core, so no unit test covers this.
Disabling depleted clients rewrote each affected inbound's settings and
aborted the whole lifecycle tick when one blob did not parse, so a node
storing a malformed blob on the master switched expiry and quota checks
off for every inbound. Unparseable node settings are now treated like a
snapshot that drops the hub's clients (the hub keeps its copy), and an
unparseable stored blob is left as it is while the client's rows are
still disabled; the rewrite also works one level deep like the others.