start.sh now detects an awg-tools vs kernel-module version mismatch
(after the tunnel fails to come up, e.g. module upgraded to 3.1 while
the image still ships old tools) and retries in userspace mode -
clients keep a working, if slower, tunnel instead of no internet.
During install the panel also warns about sibling AWG containers whose
image tools differ from the host kernel module, advising a reinstall to
rebuild the image and keep the fast kernel datapath.
setup_kernel_module used to report 'already present' for ANY installed
amneziawg version, so servers with the old 1.0.x module never received
AWG_MODULE_VERSION (3.1.20260812) when an AWG 3.1 instance was
installed. Now a version mismatch triggers an upgrade: unload the
module (deferred with a clear message while tunnels still use it),
dkms-remove the old version, then build and install the target one.
Per-server setting ssh_cooldown_base (default 30s, range 5-300) for the
SSH circuit breaker introduced with the event-loop fix. Editable in the
server management modal with a 'leave it alone when healthy' hint, and
shown in the host tuning modal. get_ssh applies edits to the pooled
connection without dropping it.
Long-lived pooled transports were dying between polls on servers behind
stricter NAT/firewall state timeouts, forcing a full SSH handshake on
nearly every poll cycle.
Consecutive connect failures now grow the cooldown 30s -> 60s -> 120s,
capped at 300s, and the first successful connect resets the streak.
connect() itself records failures, so the reconnect-and-retry path in
_run_command_locked feeds the breaker too; fail-fast 'backing off Ns'
behavior is unchanged.
Linking an existing WireGuard peer to a user crashed with
'WireGuardManager.get_client_config() takes 3 positional arguments but
5 were given': the user-link endpoint called the manager directly,
bypassing the _manager_call shim, and passed the AWG-style argument
list (protocol, client_id, host, port).
- WireGuardManager.get_client_config now takes an optional port; when
omitted it falls back to the listen port from the server config, so
existing direct callers keep working.
- The link endpoint goes through _manager_call like every other call
site, so each manager gets its own signature.
Adds tests/test_wireguard_get_client_config.py (3 cases).
A manually saved client config indexes its whole DNS line into
userData.dns — including the v6 resolver on dual-stack peers. The next
regeneration then appended it a second time. Now the v6 address is
added only if not already present in the DNS list.
Dual-stack client configs used to always append Cloudflare's
2606:4700:4700::1111. Now the resolver is stored in the server config
as a commented '# DNS6 = ...' key at install (same pattern as MTU/DNS,
which awg-quick would otherwise choke on) and read back via
_get_dns6(): per-client override (userData.dns6) > config key >
built-in default. Existing instances without the key fall back to the
same Cloudflare default, so behavior is unchanged until edited.
- app.py handed every /static response `max-age=15552000, immutable`, but
only the assets built through static_version() carry ?v=<mtime>. The
favicon, the icons, qrcode.min.js, searchable-select.js, the vendored
CodeMirror bundles and ReDoc keep one URL forever, so a redeploy could not
reach a browser that had already cached them. Immutable is now limited to
fingerprinted URLs; the rest get an hour plus a revalidation.
- WireGuardManager._apply_bw_limits ran `docker cp ... && docker exec ...`
through run_sudo_command, which only privileges the head of the chain, so
the exec ran as the unprivileged SSH user. Wrapped in one `sh -c`, the
pattern tests/test_sudo_command_chains.py enforces.
- toggleConnection() restored the row only in the error path. When
loadConnections() fails after a successful toggle, the row it was going to
rebuild stays disabled with a spinning bulb. Restore now runs in `finally`.
- The add-user modal starts on role "none", where the password is optional,
but the label said "required" until the select fired its change event.
Tests: the bandwidth chain joins the sudo-chain suite, and a new
tests/test_static_cache.py pins both cache lifetimes.
Today a client leaves to the internet from the same server it connected to.
Exit nodes split the two: clients connect to an *entry* node (reachable,
possibly behind DPI) and egress from an *exit* node (clean IP, other country).
One exit serves many entries and keeps serving its own AWG clients.
client --AWG--> [entry] amnezia-awg2 --WG/AWG link--> [exit] amnezia-exit --> internet
awg0 10.8.1.0/24 transit0 10.9.0.0/24
exit0 10.9.0.N (Table=off) one [Peer] per entry, /32
Exit side: a new protocol `exit` = container `amnezia-exit`, an AmneziaWG
listener on 55520/udp over the transit subnet 10.9.0.0/24. Peers are panel
nodes, never end users, and live in their own peersTable. ExitManager
subclasses AWGManager and closes every client-facing method.
Entry side: a second interface `exit0` inside the existing AWG container with
`Table = off`, plus policy routing - `from <client subnet> lookup 200`,
`default dev exit0` in table 200, and `blackhole default metric 1000` as a
kill-switch installed *before* the client tunnel comes up, so a container
restart cannot leak. Clients are MASQUERADEd into the entry's transit /32, so
the identical 10.8.1.0/24 of every instance can never collide at the exit.
TCP MSS is clamped, and rules 190/191 keep client<->client and AmneziaDNS
traffic local. State is file-driven: exit0.conf present means linked, and the
same shell block runs at container start and on a live (un)link.
Link crypto is WireGuard; AWG obfuscation is a per-exit-node property for hops
that cross DPI, and S3/S4 are deliberately not written on the transit - they
pad every transport packet and would eat the MTU budget of a tunnel inside a
tunnel. Private keys are generated inside the containers; the panel carries
public keys and a per-peer PSK only.
exit_link_service.py orchestrates linking with per-(entry, protocol) locks,
SSH off the event loop, a 15-second handshake gate that rolls back, and
lifecycle hooks for every way a link can go stale: exit uninstalled, stopped,
deleted, host cleared, entry reinstalled, container gone, backup restored.
Servers get a stable `uid` (already upstream) so reorder and delete cannot
break a link.
IPv4 only for now: while linked, client IPv6 is refused with `unreachable`
rather than leaking out of the entry.
Also included: an egress check that curls a public IP service bound to the
tunnel gateway (the client's exact path) and compares it with the direct one;
optional DNS resolution at the exit node; a default exit node applied to new
AWG installs; and reconciliation after a protocol restore.
244 tests pass. CURRENT_VERSION is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paramiko raises a bare EOFError/SSHException when the transport goes away
mid-command, and `str(exc)` on those is an empty string. run_command passes
that empty string on as the failure reason, and the managers interpolate it
straight into their messages - about 30 `{err}` sites across managers/ - so a
dropped connection surfaces as "Failed to configure container: " with nothing
after the colon, and in the log as "Command exited with code -1: ".
The SSH layer now falls back to the exception type when the message is empty,
and labels the read failure explicitly: "SSH connection lost while running the
command (EOFError)". Three call sites: the two reconnect paths and the
stdout/stderr read.
Seen in practice where a panel manages a host that rate-limits SSH (ufw's
`limit` on port 22): a burst of connections gets dropped mid-command and every
resulting error message was blank.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_host_tuning() passes a multi-line script to run_sudo_command(), which
builds `echo <pw> | sudo -S -p '' <script>`. Only the first line is elevated;
everything after the first newline runs as the unprivileged SSH user. The
`docker ps -a` loop is on line 8, so on hosts where the login user is not in
the docker group the container list comes back empty and the Host tuning modal
shows the host rows with no containers - no error, just missing data.
run_sudo_script() is what the rest of the file already uses for this (five
call sites in awg_manager alone): it uploads the script and runs the whole
thing under sudo. get_host_tuning was the one place that did not.
tests/test_host_tuning.py drives get_host_tuning through a fake SSH layer that
records which transport the caller used and fails if it is not the script one,
then checks the parsed host keys and the running/stopped container list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`run_sudo_command("a && b")` builds `echo <pw> | sudo -S -p '' a && b`, so
sudo elevates only the first command of the chain. Everything after `&&` runs
as the unprivileged SSH user and fails on anything root-owned. Four call sites
were affected:
- backup_manager: restoring a protocol backup copies the archive out of
/opt/amnezia/backups, which is root-owned, so the restore died with
`cp: cannot stat '/opt/amnezia/backups/awg2/...': Permission denied` right
after reporting that it had found the archive.
- app.py: the inline backup-download chain has the same shape.
- dns_manager: `docker network create` and the per-container attach loop -
the attach silently never happened, so containers started before AmneziaDNS
were left off amnezia-dns-net.
- adguard_manager `_ensure_network`: same.
Each chain now goes to one shell via `sh -c '<chain>'` with the arguments
quoted, so the whole chain runs under sudo. tests/test_sudo_command_chains.py
records the commands a fake SSH layer receives and asserts every privileged
chain is wrapped, plus a unit check on the helper itself, so a new bare `&&`
in these paths fails the suite.
The one error in the suite on this branch is the pre-existing
`import pytest` in tests/test_playwright_e2e.py, unrelated to this change
and fixed separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cat exits non-zero both when docker exec fails and when the table file
does not exist yet (fresh instance). Distinguish via 'docker exec true':
raise only when the container itself is unusable; an absent table simply
means no reservations yet. Fixes the test regression in CI
cat exits non-zero both when docker exec fails and when the table file
does not exist yet (fresh instance). Distinguish via 'docker exec true':
raise only when the container itself is unusable; an absent table simply
means no reservations yet. Fixes the test regression in CI
Closes the conflict scenario from #138: disabling a client removed its
[Peer] from the active config while its IP stayed in clientsTable, so
the allocator (which read only the active config) could hand that IP to
a newly created client; re-enabling the original client then activated
two peers with the same address.
AWG (all versions) and WireGuard:
- allocation now counts reservations from clientsTable (disabled clients
included) in addition to the active config; only deletion frees an IP
- re-enabling validates that the stored IP is not owned by another
client (table) and not present in the active config; on conflict the
operation is rejected with a clear error
- if clientsTable cannot be read, allocation fails loudly instead of
treating the reservation pool as empty
WireGuard additionally derives the subnet from the live server config
instead of the built-in default, so imported instances with custom
subnets allocate correct addresses.
Xray needs no change: clients are identified by UUID, no IP allocation.
Fixes#138
Closes the conflict scenario from #138: disabling a client removed its
[Peer] from the active config while its IP stayed in clientsTable, so
the allocator (which read only the active config) could hand that IP to
a newly created client; re-enabling the original client then activated
two peers with the same address.
AWG (all versions) and WireGuard:
- allocation now counts reservations from clientsTable (disabled clients
included) in addition to the active config; only deletion frees an IP
- re-enabling validates that the stored IP is not owned by another
client (table) and not present in the active config; on conflict the
operation is rejected with a clear error
- if clientsTable cannot be read, allocation fails loudly instead of
treating the reservation pool as empty
WireGuard additionally derives the subnet from the live server config
instead of the built-in default, so imported instances with custom
subnets allocate correct addresses.
Xray needs no change: clients are identified by UUID, no IP allocation.
Fixes#138
With several wg-easy containers on one host, detect_source picked the
first running one - mixing the client list from the requested panel
with the server identity of a different container. Now the container
whose published TCP port matches the authenticated web panel wins
With several wg-easy containers on one host, detect_source picked the
first running one - mixing the client list from the requested panel
with the server identity of a different container. Now the container
whose published TCP port matches the authenticated web panel wins
- When AWG 2.0 is already installed on the server, wg-easy import now
creates the next free instance (awg2__2, amnezia-awg2-2 container)
instead of failing with a bare 400
- Import progress and errors are shown in a log pane inside the modal;
the modal no longer closes silently on failure, and stays open on
success until the user closes it (page reloads to show the instance)
- apiCall keeps the error payload so the UI can render the log
- When AWG 2.0 is already installed on the server, wg-easy import now
creates the next free instance (awg2__2, amnezia-awg2-2 container)
instead of failing with a bare 400
- Import progress and errors are shown in a log pane inside the modal;
the modal no longer closes silently on failure, and stays open on
success until the user closes it (page reloads to show the instance)
- apiCall keeps the error payload so the UI can render the log
- iproute2 added to the WG image; older containers get it installed on
the fly when a limit is applied (no rebuild needed)
- bwlimits flat file + HTB/policer via tc on wg0, re-applied at boot
by the start script and on every clientsTable save
- set_speed_limit(client_id, max_speed) mirrors the AWG API, so the
rename/speed popover now offers a limit for WireGuard clients too
w0rng/amnezia-wg-easy sources carry AmneziaWG obfuscation params
(Jc/Jmin/Jmax/S1-S4/H1-H4) in the [Interface] section. Importing such
clients into a plain WireGuard instance would break their configs, so
the importer now reads the source wg0.conf from the container, detects
the obfuscation and installs an AWG 2.0 instance with the exact same
params instead. Plain wg-easy sources still land on WireGuard.
Preview now reports the detected listen port, obfuscation presence and
the resolved target protocol.
Adds an 'Import from wg-easy' button on the server page. The panel
fetches the client list from a running wg-easy (v14 / w0rng fork) or
wg-easy v15 instance through the server's own SSH connection (web API
on 127.0.0.1, nothing needs to be exposed), shows a preview with
selectable clients, and on confirmation:
- stops the old wg-easy container (not deleted)
- installs the panel-managed WireGuard instance on the same UDP port
- restores the original server key, subnet and all client keys/PSKs/IPs,
so existing client configs keep working unchanged
- writes a backup copy of wg0.json to /root on the server
The WG container start script now accepts a custom subnet so NAT rules
match the imported network instead of the panel default 10.8.2.0/24.
The DKMS script used 'set -e' and surfaced a bare 'build failed' on any
error. Now each step (headers install, repo clone, dkms add/build/
install, modprobe) reports a specific reason, e.g. 'headers for the
running kernel 6.1.0-9-amd64 are no longer in the repos - upgrade the
kernel and reboot', and the reason is shown in the install log.
The DKMS script used 'set -e' and surfaced a bare 'build failed' on any
error. Now each step (headers install, repo clone, dkms add/build/
install, modprobe) reports a specific reason, e.g. 'headers for the
running kernel 6.1.0-9-amd64 are no longer in the repos - upgrade the
kernel and reboot', and the reason is shown in the install log.
w0rng/amnezia-wg-easy sources carry AmneziaWG obfuscation params
(Jc/Jmin/Jmax/S1-S4/H1-H4) in the [Interface] section. Importing such
clients into a plain WireGuard instance would break their configs, so
the importer now reads the source wg0.conf from the container, detects
the obfuscation and installs an AWG 2.0 instance with the exact same
params instead. Plain wg-easy sources still land on WireGuard.
Preview now reports the detected listen port, obfuscation presence and
the resolved target protocol.
Adds an 'Import from wg-easy' button on the server page. The panel
fetches the client list from a running wg-easy (v14 / w0rng fork) or
wg-easy v15 instance through the server's own SSH connection (web API
on 127.0.0.1, nothing needs to be exposed), shows a preview with
selectable clients, and on confirmation:
- stops the old wg-easy container (not deleted)
- installs the panel-managed WireGuard instance on the same UDP port
- restores the original server key, subnet and all client keys/PSKs/IPs,
so existing client configs keep working unchanged
- writes a backup copy of wg0.json to /root on the server
The WG container start script now accepts a custom subnet so NAT rules
match the imported network instead of the panel default 10.8.2.0/24.
The pooled SSHManager is shared between request handlers and background
threads (traffic sync, conn monitor). When one side's exec failed, its
reconnect closed the shared client while the other side was mid-command:
'NoneType open_session', EOFError in open_sftp, a burst of 500s, then
30s of fast-fail from the connect cooldown.
Add an RLock around exec_command and all SFTP operations (RLock because
run_command retries by re-entering itself). Lock ordering is
exec -> conn, connect/disconnect never take the exec lock, so no
deadlock. Failed execs now serialize the reconnect too: other threads
simply wait and then run on the fresh transport.
prepare_host() merged ipv6/ip6tables keys into /etc/docker/daemon.json and
ran 'systemctl restart docker' mid-install. That killed every container
on the host (running tunnels dropped ~20s) and, when the panel itself
runs in Docker on the managed host, killed the panel together with its
own install request.
Now the daemon restart happens only when daemon.json actually changed
AND no amnezia-* or panel containers are running. Otherwise the restart
is deferred and the install log warns the admin that tunnels stay
IPv4-only until 'systemctl restart docker' is run manually. A successful
restart is also logged.