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.
Dedupe, tombstone stripping and the lifecycle lift decoded a node's
whole settings blob into generic maps and wrote it back indented, so a
deeply nested value anywhere in it cost the square of its depth on every
merge (10 KB became 50 MB). They now split the blob one level deep, read
only email, expiryTime and enable of each client entry, keep everything
else as raw text and write the result compactly.
Three statement shapes bound one SQL variable per row and failed past
SQLite's limit, which a node can reach by itself with the clients and
inbounds it reports: the depleted-client lookup and disable in
disableInvalidClients (stopping every expiry and quota check), the
whole-slice Saves of renewed inbounds and traffics (stopping renewals),
and Preload("ClientStats") over all inbounds (stopping config builds).
The IN lists are chunked, the Saves go 100 rows at a time, and client
stats are attached in chunks instead of preloaded.
The node Remote keeps a fingerprint, a remote id and possibly an alias
per inbound tag. When the master swept an inbound the node stopped
reporting, those entries stayed, so a node rotating tags grew the maps
for as long as the master ran. The sweep now hands every removed tag to
Remote.ForgetInbound, which drops all of it in both tag forms, and a
master-driven delete uses it too. The sweep's call is wiring;
TestRemoteForgetInboundDropsEveryTrace covers what it drops.
Two shapes of a node's inbound list cost the master without bound:
- an inbound listed many times was merged once per listing, every pass
walking its whole client set again; the first listing now wins and the
repeats are dropped;
- disabled inbounds were never swept, an exemption meant for inbounds the
master created but has not delivered yet, so a node could report any
number of them and the master kept them all; the exemption now covers
only rows without an origin, i.e. never reported by the node.
A peer's panelGuid is copied into nodes.guid and into every inbound row
it reports (an indexed column), a forwarded origin GUID likewise, and a
GUID-keyed IP tree or a master's global-traffic push keys one row per
client. One oversized value therefore multiplied into gigabytes stored.
GUIDs over 64 bytes (panels mint 36-character UUIDs) now fail the
heartbeat, fall back to the reporting node's key, or are dropped.
SSRFGuardedDialContext tried each address of a name in turn with a fresh
10 s, so a name resolving to thousands of unanswering addresses held a
dial (and the caller's goroutine) for hours; a node can choose such a
name for itself. The whole call now shares one 10 s budget, divided over
the remaining addresses with net.Dialer's 2 s floor. Multi-address DNS
answers cannot be produced in a unit test here, so no test reaches it.
Node responses are untrusted input on the master, and a few shapes still
cost it without bound under the 64 MiB body cap:
- a success=false message became an error (logged, stored as lastError)
in full; it is now cut to the 8 KiB the non-200 diagnostic already uses;
- each "{}" of a reported list decodes into a whole struct, so lists are
now decoded entry by entry with per-endpoint caps (1024 descendants,
65536 inbounds and host groups, 1M client-IP rows), and the GUID-keyed
trees and maps are capped at 1M values;
- a TLS handshake the node never answers kept a socket and goroutines
alive forever, since the transport keeps dialing for its pool after the
request gives up; node transports now give up after 10 s.
The master's node clients followed redirects to any host, presenting the
same panel-wide credentials (the mTLS client certificate) and replaying
the request there, so one node could have the master push to another.
Node clients and the certificate-pin probe now stop at a redirect off
the original host:port and report the 3xx as the node's answer. The
probe change is wiring; TestNodeClientsDoNotFollowARedirectToAnotherHost
covers the guard.
InitLocalizer filled the shared bundle in place, and the Telegram bot
runs it again while the panel is serving, so a request could read the
bundle's maps while they were being written and the process died on a
concurrent map access. Build the bundle privately and publish it, and
the bot and web localizers, through atomic pointers; the lazy load in
the middleware publishes only if nothing else did first.
The shared zstd decoder grew its output per frame, copying everything
decoded so far for each extra frame, so 30 KB of tiny frames cost about
8 GB of allocation and seconds of CPU per request. The output buffer is
now sized once from the first frame's declared size (Compress writes a
single frame) with decoding capped at that capacity, and callers waiting
for a decoder worker no longer each hold a buffer.
The envelope middleware buffered and decoded any request carrying its
headers, GETs included, which the router-wide body cap skips and which
read-only tokens may call; its raw read had no cap of its own. Refuse an
envelope on safe methods and read at most the decoded-size limit.
The live-update socket accepted any Origin whose host name matched, but
cookies do not separate ports or schemes, so a page served by another
service on the same host name could open it with the admin's session.
Only http(s) origins are accepted now (https when the panel terminates
TLS itself), and the port must match whenever the Host header has one.
SetLoginUser added the user to whatever session the login request
carried, so its CSRF token outlived the login. Anyone can fetch an
anonymous session with its token, and a host on a sibling domain can
plant that cookie in the admin's browser. Clear the session before the
user is stored; the next page load or token fetch mints a new token.
A failed login keeps its username in the in-memory log buffer, the
limiter table and the notifier queue, and the route accepted any body
the global cap allowed. The login route now takes at most 16 KiB and
answers a username over 256 bytes with the generic failure before any
of those see it; the user setters refuse such a name too, so no stored
username can be one the login route turns away.
The login limiter counted failures per (address, username) only after
the check finished, so attempts sent together all passed before the
first failure was recorded, and one address could own every record of
the bounded table and push out the one holding its own failures.
An admitted attempt now holds a slot until it reports back, and an
address is blocked as a whole after five times the per-username limit
spread over any names, which also caps the records one peer can own.
Remote routing sources are fetched documents the panel re-renders on
every subscription request. A Clash YAML document stays small through
aliases, which decode to one shared value but are written out in full,
so it now has to fit a 4 MiB written-size budget and 16 levels of
nesting when it is accepted (and when a persisted copy is loaded). A JSON
routing profile could repeat route groups, each copy repeating every
site list; a group now counts once, since a repeat can never match.
The YAML encoder writes multi-line strings as block scalars and gets
CR and the Unicode line breaks (NEL, LS, PS) wrong inside them, so a
remark, path or header name with such characters could continue as YAML
outside its scalar. Strings with a non-printable rune or invalid UTF-8
are now written double-quoted with escapes, map keys included.
A client's external subscription is fetched from a third party on every
subscriber refresh, and the fetcher trusted it more than its siblings:
- it dialled any address, redirects included; it now goes through the
SSRF guard like the outbound subscription and remote routing fetchers;
- it forwarded any line containing "://"; it now keeps only server share
links, so app deeplinks and plain URLs never reach the subscriber's app;
- it bounded the body but not the link count (now 1000) or the headers
(now 64 KiB), and stored the raw error, URL included, on every row; the
stored error is now host-only and at most 1 KiB.
The link parser also refuses links that nest JSON past 16 levels, since
the JSON subscription pretty-prints it at quadratic cost, and external
configs are marshalled compactly until the final, single indent pass.
The traffic poll read every Xray counter in one QueryStats reply under
gRPC's default 4 MiB receive limit; past it the whole poll failed and
no client was counted, so quotas stopped being enforced. Outbound
counters carry tags derived from third-party link remarks, so one long
remark was enough.
Poll the user, inbound and outbound families separately with a 64 MiB
limit, keep counting the families that answer, and cap a subscription
link's slug at 64 runes so the tag stays small to begin with.
The panel runs xray-core's conf.Build() in its own process on inbounds
and on outbounds that come from pushes and subscriptions. Some values
make it allocate or loop without bound (an XHTTP sessionIDLength, udphop
port lists, any number of finalmask entries, each xmc one costing an RSA
key), and certificate paths are read whole whatever they name.
Refuse those before any build: sessionIDLength above 1024, more than one
port range's worth of udphop ports per stream, more than 16 masks per
stream, more than 64 xmc masks per outbound subscription, and certificate
or key files that are not regular files of at most 1 MiB. Inbound saves,
the TUIC readers and the certificate-pin helper apply the file rule too;
the pin helper also stops looking at paths that name files on a node.
The node certificate probe builds a transport for one request and dropped
it with the connection still parked, and the remote routing client kept
idle connections with no timeout; either stayed open for as long as the
remote side wanted. Close the probe's idle connections when it returns,
and turn keep-alive off for routing downloads, which are minutes apart.
The routing client dials only public hosts, so no loopback test reaches
it; TestFetchCertFingerprintLeavesNoIdleConnection covers the probe.
The refresh rewrote the whole row it read before a download whose length
the provider controls, so an admin's delete, disable or edit made in the
meantime was undone; Update had the mirror problem. Both now write only
their own columns, and a refresh of a row that was deleted or re-pointed
stores nothing.
Around the same path:
- the preview fetches without creating (and leaking) a scratch row, and
Create keeps enabled=false instead of falling back to the column default;
- fetch errors name the host but not the subscription URL, which is a
credential and ends up in the log and in lastError;
- each fetch closes its transport's idle connections;
- one document loads at most 2000 links, and the cut is reported;
- wireguard outbounds from a subscription always run on the userspace TUN;
- editing a subscription schedules an Xray reload like delete and move do.
ValidateOutboundConfig ran only conf.Build(), but the core parses some
fields again in its handler constructors, and a failure there stops the
whole core at start: wireguard local addresses and peers, the vless
encryption keys and padding, and shadowsocks 2022 keys. Mirror those
parses with the core's own helpers, and turn a panic in the loader into
a rejection so one bad link cannot unwind the caller.
A share link describes a remote server, but extra=, fm= and the vmess JSON
copy provider objects verbatim, so a link could carry keys that only make
sense on the importing host. Both link parsers now drop dial-redirect and
host file keys (matched case-folded, as the core binds them).
Outbound subscriptions also refuse outbounds that dial this host or its
network without allowPrivate, any that target the core's own API or
metrics port, and any that take their server from DNS records. Rows
stored earlier are filtered the same way when the config is built.
The session cookie key is generated once and kept forever. Keys stored by
older releases came from a weaker generator, and a stored value cannot be
told apart from a current one, so drop it once through a seeder and let
GetSecret store a fresh crypto/rand key on the next start. Admins log in
again once after upgrading; fresh installs record the seeder up front.
The host, JSON subscription and outbound mask editors now offer PattN's
two chained tlshello fragment presets; a pick replaces the TCP mask list.
The fragment length validator rejected a 0 minimum in every entry, but
xray-core only refuses it in the last lengths entry. An earlier 0 is an
empty segment, which the tlshello-0-len preset relies on.
Adds PattN's semi-python and real-firefox client cipher lists as presets
in the cipher suites picker. A preset replaces the whole list because the
order is part of the ClientHello.
The inbound TLS form now warns when cipher suites are set under a uTLS
fingerprint other than "unsafe": clients ignore them there, while an
empty fingerprint is already pinned to unsafe in share links.
Xray builds the ClientHello with uTLS for every fingerprint except
"unsafe", and GetFingerprint("") resolves to chrome, so cipherSuites are
dropped unless the fingerprint is exactly "unsafe". A host or inbound
that set cs and left the fingerprint blank shipped links whose cs did
nothing in v2rayN, PattN and every other Xray-based client.
Raw links (query and vmess JSON), the JSON subscription and the panel's
own link generator now emit fp=unsafe when cs is present and the final
fingerprint is empty. An explicit fingerprint is left as configured.
The access-log scanner and the cross-node merge stored the same IP list in
opposite orders, so neither saw a row as unchanged, and the merge re-read
every preloaded row and re-upserted every attribution row each sync cycle.
All writers now share one canonical encoder (timestamp, then IP ascending),
the first CAS is seeded from the preload, and identical attribution rows are
skipped. A create race between the scanner and node sync now merges into the
winning row instead of dropping the loser's IPs, which on PostgreSQL also
aborted the scanner's whole scan transaction.
Closes#6927
backfillClientStats loaded every column of every client once per inbound,
although enrichment only reads UUID, email and subscription ID. Each inbound
list read (panel list, the master's node traffic poll, Xray config builds,
bots) repeated that wide join per inbound. Identities now come from one narrow
clients/client_inbounds projection per 400 inbounds, honouring the supplied
transaction; stats output, sibling-traffic append order and the #6436
clients-table source of truth are unchanged.
Closes#6926
The public IPv4/IPv6 lookup was cached for the life of the panel process, so
Overview kept showing an old address after the server's external IP changed,
and a failed first lookup pinned N/A until a restart. Addresses now refresh
every five minutes, a failed IPv4 lookup retries after a minute, failed IPv6
lookups back off 1/2/5/15 minutes, and a temporary failure keeps the last
known address. Status sampling still never waits on the lookup.
* feat(amneziawg): add opt-in Linux kernel datapath
Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply.
Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed.
* fix(amneziawg): align readiness and endpoint reporting
Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support.
Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass.
* style(amneziawg): format readiness test
* test: reproduce kernel AWG client validation gaps
* test(service): cover kernel AWG shared client fanout
* fix(amneziawg): validate kernel client fanout and config values
* test(amneziawgkern): cover owned interface lifecycle recovery
* fix(amneziawgkern): recover owned interfaces on restart
* test(amneziawgkern): retain visibility after recovered cleanup failure
* fix(amneziawgkern): report recovered interfaces while cleanup retries
* style(amneziawgkern): satisfy static analysis
* test(amneziawgkern): cover long rendered peer comments
* fix(amneziawgkern): accept long generated config comments
* test(amneziawgkern): refuse foreign occupied interfaces
Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries.
* test(amneziawgkern): cover stale interface collisions
Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown.
* fix(amneziawgkern): verify interface ownership before mutation
Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs.
Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check.
* build: update Go toolchain to 1.27.3
* test(amneziawg): reproduce unsafe key rotation recovery
Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure.
* fix(amneziawg): recover interrupted key rotations
Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership.
* fix(scanner): align Go image with module version
* fix(docs): pin pnpm release with bootstrap fix
pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely.
* fix(xray): build core from pinned module
The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets.
* fix(tests): stabilize local network fixtures
Keep the TUIC stream fragmentation check on a working UDP loopback path,
allocate the AmneziaWG fixture ports dynamically, and wait for a real
Xray stats response before starting API mutation assertions. Recreate
failed readiness channels so an early connection cannot stall the probe.
Verified host and offline parallel Go suites, repeated occupied-port and
UDP tests, race/shuffle runs, and golangci-lint. Production behavior and
dependency versions are unchanged.
* fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK
main gained the sk-SK locale after this branch added its kernel-mode keys
to the other 13, so i18n-dead-keys.test.ts failed on the merged tree.
* refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick
awg-quick evaluates PostUp/PostDown through a root shell, so every value the
panel rendered into its config was one validation slip away from root command
execution. The interface is now built with direct ip/awg/nft execs from a
setconf-only file: link add, awg setconf, address, mtu, an atomically replaced
nftables table per inbound, and the IPv4 forwarding sysctl written directly.
- Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables
and configs under the panel's data dir, so nothing collides with an
operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg.
- FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker
and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel.
- A full panel stop records the final counters and tears the interfaces down,
like the embedded datapath; a stopped panel enforces no quota or expiry.
Crash survivors are still adopted without a rebuild.
- The job reads counters before reconciling, and from every managed interface,
so a client disabled for quota, or a removed inbound, is charged for its last
interval instead of losing it with the dropped peer.
- An unchanged reconcile tick forks no process (it probed the live key with
`awg show` every 10s); readiness no longer loads the module from a GET.
Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and
egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM
teardown leaving no link, table, FORWARD rule or config behind.
* fix(amneziawg): gate kernel host readiness only when a save starts the datapath
Every save of a kernel-mode inbound re-ran the host preflight, so a host that
lost its module (a kernel upgrade without a DKMS rebuild) refused even
disabling the inbound or switching it back to embedded mode. Host blockers now
apply only when the save starts the kernel datapath: a new inbound, a switch
from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded
ports, pinned listen) still apply to every save.
* feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it
A node built before kernel mode drops the unknown kernelMode field and serves
the inbound on the embedded datapath while the master's form shows kernel
mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses
to push a kernel-mode inbound to a node that has not advertised it, learning
the caps with an inbound list first when this Remote has not heard from it.
* fix(amneziawg): pin the kernel module installer to verified commits
The installer ran as root and built whatever upstream tag sorted newest at
install time. It now clones fixed tags (module v3.1.20260906, tools
v3.1.20260812) and refuses unless each resolves to its recorded commit. The
udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat
upstream, so the patch never matched.
* fix(i18n): translate the kernel-mode AmneziaWG strings
All thirteen non-English locales carried the English text verbatim.
* fix(amneziawg): skip the kernel traffic flush when no kernel interface runs
Shutdown listed the desired AmneziaWG inbounds from the database even on a
panel with no kernel interface, an unneeded query on every stop. A server
stopped before its database was opened panicked on it, which turned
TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>