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.
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.
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).
Both files were submitted in #143 and #147, before the exit node existed here,
so their exit-specific halves had to be stripped to keep them importable. That
code is in the tree since #149, and the stripped copies now test less than they
could:
- test_ssh_error_text.py gains ExitLinkMessageTests: an SSH whose sudo call
returns -1 with no output at all (a dropped transport) must make exit_link
and exit_unlink raise a message that names the exit code, not
"exit link apply failed: " with nothing after the colon. That is the failure
the empty str(EOFError) produced, and it is the reason #147 exists.
- test_sudo_command_chains.py pins amnezia-exit, not amnezia-awg2, as the
representative container that DNSManager attaches to amnezia-dns-net. Both
are in vpn_containers, but only the exit one silently breaks DNS on an exit
node when it falls out of the list.
No production code changes.
The merge left two handlers on POST /api/servers/{server_id}/protocol/rename.
Starlette matches in definition order, so the first one won and the copy that
caps the name at CUSTOM_PROTOCOL_NAME_MAX and strips the protocol key never
ran - the cap added in #144 was silently dead, and /docs showed the endpoint
twice. Kept the capped handler.
tests/test_route_uniqueness.py now fails on any duplicate (path, method).
- CachedStaticFiles matched the substring `v=`, so `?rev=1` looked
fingerprinted and earned the 180-day immutable lifetime. It parses the
query now and looks for a parameter actually named `v`.
- saveRenameProtocol() restored the button label from a server-rendered
`'{{ _("save") }}'` string literal; a locale whose translation carries a
quote would break the whole inline script. Uses the runtime helper and
textContent instead.
- The add-user modal kept the "password optional" hint up after the role
changed to one that requires a password. It is hidden with the label now.
- 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.
Two defects came in with the merges of #149-#152 and turn the test suite red:
- templates/server.html carries two copies of the rename-protocol block, so
'let renameProtoTarget' is declared twice and the whole inline script dies
with a SyntaxError - every button on the server page stops working. Kept
the copy that restores the button label and re-enables the input.
- tests/test_host_tuning.py still expects the pre-exit-node grep. Since #145
and #149 the probe greps '^amnezia-(awg|exit)'.
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>
"Error loading connections: JSON.parse: unexpected character at line 1 column
1 of the JSON data" is what the panel shows whenever a response is not JSON,
and it says nothing about the actual failure. Two halves:
The app has no handler for unhandled exceptions, so Starlette answers with a
plain-text "Internal Server Error" - unparseable for every caller that does
`await res.json()`. /api/* now answers `{"error": "Internal server error"}`
with the traceback logged; pages keep the plain-text body, since a browser
showing an error page is fine.
apiCall read the body as JSON unconditionally, so a proxy error page, a
restart mid-request or an empty body surfaced as a parser complaint. It now
reads text, parses defensively and throws "HTTP <status>: <first line of the
body>", which points at what happened. The existing err.payload passthrough
is kept, so the wg-easy import log still survives the throw, and data.detail
is consulted for FastAPI's own validation errors.
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>
tests/test_server_template_integrity.py calls `node --check` through
subprocess.run() unconditionally. Without node on PATH that raises
FileNotFoundError and fails the suite rather than skipping - verified by
running it with PATH=/usr/bin:/bin. It now carries the same
`skipUnless(shutil.which('node'))` guard the other template-JS check uses.
static/sw.js precached three URLs nobody requests. Both fetch branches match
on the exact URL, and the templates ask for /static/favicon.svg,
/static/js/qrcode.min.js and /static/js/searchable-select.js with no query -
so the `?v=${VERSION}` entries never served a request, and the favicon ended
up stored twice (cache-first misses the versioned entry, fetches the plain
URL, caches that too). style.css keeps its query: the templates request it as
?v={{ static_v }} and the worker is registered with the same value, so that
one does match. Freshness is unaffected either way, since CACHE is named after
VERSION and a new version starts from an empty cache.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most of the panel's JavaScript lives inline in templates/*.html, and every UI
string lives in translations/*.json. Neither is covered: a syntax error in a
template only shows up as a silently dead page in the browser, and a broken
translation file makes the panel render raw keys - `load_translations` catches
the JSONDecodeError and leaves the dictionary empty.
The JSON one is the easier trap. Two branches that both append keys at the end
of a translation file merge without a conflict and drop the comma that used to
end the block, which git has no reason to flag. The file stays plausible,
5 files x ~700 keys, and the failure surfaces as English keys in the UI.
test_template_js.py extracts every <script> block that has no src, strips the
Jinja tags (`{{ ... }}` -> a literal, `{% ... %}` -> nothing), and runs
`node --check` over the result; it skips itself when node is missing. The
second test json.loads every translations/*.json.
Ran against the current tree: both pass.
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>
tests/test_playwright_e2e.py starts with a bare `import pytest`, but pytest is
not in requirements.txt - only playwright is. CI installs requirements.txt and
runs `python -m unittest discover -s tests`, which imports every module under
tests/, so collection aborts on that import and the whole suite fails.
`main` has been red since the module landed: the runs for #134, the v1.6.4 tag
and the follow-up commit all report `FAILED (errors=1)` with
`ModuleNotFoundError: No module named 'pytest'`, and none of the 152 real
unit tests are reported.
The import is now guarded and raises unittest.SkipTest, so `unittest discover`
skips the module and keeps running the rest. Nothing changes for pytest users:
with pytest installed the import succeeds and `pytest tests/test_playwright_e2e.py`
behaves exactly as before. The module docstring now also says it expects a
panel already serving on BASE_URL.
An alternative would be adding pytest to requirements.txt, but that pulls a
test runner into the runtime dependencies of the panel, and `unittest discover`
still would not run these tests - they are pytest fixtures and plain classes,
not unittest.TestCase.
Before: Ran 152 tests ... FAILED (errors=1)
After: Ran 152 tests ... OK (skipped=1)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The start script of an AWG container was spelled out twice, byte for
byte: in _upload_start_script() (install) and inline in
save_server_config() (raw config editor). Any change to forwarding, NAT
or the userspace guard had to be made in both places, and nothing
checked that they stayed identical.
Extract three pure builders - _dockerfile_content(), _docker_run_cmd()
and _start_script_body() - plus _render_start_script() and
_write_start_script() (upload + docker cp + chmod, no restart) on top of
them. _upload_start_script() is now write + restart and loses the port
and awg_params parameters it never used; save_server_config() calls
_write_start_script() with the resolved config path as before.
The builders are pinned by fixtures captured from the previous code by
running install_protocol()/save_server_config() against a fake SSH, so
the generated Dockerfile and start.sh are proven identical for awg,
awg3 and awg_legacy. The one intentional difference: docker run emitted
`--name <container>` twice; it is emitted once now and the test spells
out that delta against the old fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Servers are addressed by their position in data['servers']: reorder
rebuilds the list and remaps user_connections, delete pops an entry and
shifts the ones after it. Any new cross-server reference (a server that
routes through another one) keyed by that index would silently point at
the wrong host after either operation.
Give every server record a `uid` (uuid4 hex). It is minted only in write
paths - the startup migration, api_add_server and the data.json restore
endpoint - never inside load_data(), because two concurrent readers
would otherwise assign different ids to the same record and the last
writer would win. find_server_by_uid() resolves a uid back to (index,
record) and treats an empty or unknown uid as not found.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AWGManager caches the server config for 10 s (_server_config_cache),
but only save_server_config() drops that cache before writing. The other
writers - _insert_peer_sorted, both branches of toggle_client,
remove_client and _write_server_config - rewrite awg0.conf in the
container and leave the stale copy in place, so a second read on the
same manager instance within the TTL returns the pre-write content:
_get_next_ip hands out the address it just assigned, and a peer removed
and re-added through one instance is resurrected from the cached text.
Today every API call builds a fresh manager, so the window is confined
to a single request; the upcoming exit-node work (remove + re-add of a
transit peer in one operation) runs straight into it. Add
_invalidate_config_cache() and call it after each of the five writes,
with a test that drives the real methods against an in-memory container
filesystem and fails on the unpatched code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>