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.
- 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.
`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>