- User enable/disable button is now a bulb (off = greyed, CSS class),
with an in-flight spinner and the whole user card locked/dimmed.
- The connections modal shows the peer's live name from the instance
(matched by client id) instead of the panel-side record name.
- Server name in the modal is a target=_blank deep link to
/server/N?protocol=..&highlight=<client_id>: the server page switches
to the right instance, frames the peer card (2px #7038da) and scrolls
it to the viewport center; the frame survives the 5s live poll.
/api/users/{id}/connections now enriches each record with live peer
data (IP, enabled state, handshake/transfer totals, speed limit) via
one SSH session per (server, protocol); unreachable servers degrade
gracefully to DB-only fields.
The modal renders the same client-item layout as the server page:
avatar letter, name + IP, server/protocol/date/badges, and working
rename / config / toggle (spinner + locked row) / delete actions.
- Link-existing flow now supports selecting several peers at once
(Ctrl); each link is applied independently with a summary toast,
partial failures do not abort the rest.
- Add-connection modal remembers the last used mode/server/protocol
(localStorage) and restores them on next open.
- Linking or creating a connection refreshes the users grid via AJAX
and live-updates an open connections modal instead of reloading the
whole page.
- Server-side guard against double-linking a peer: a peer already
assigned to any panel user is rejected with a message naming the
current owner (a peer is a single on/off entity on the server;
sharing it between users made both cards control the same peer).
- Linking an existing peer no longer pops up the config modal.
- Translations for all 5 locales.
- POST /api/users/{id}/connections/unlink: detaches a key from the user
without deleting the peer on the server (admin-only, 404 if not found)
- users page: scissors button per linked connection, server/instance
names open the server page in a new tab and highlight the peer row
- server page: ?protocol=&highlight= deep link handling, peer row
gets .peer-highlight CSS class and scrolls into view
- translations for all 5 locales
- api_get_server_clients called manager.get_clients(protocol) directly,
but WireGuardManager.get_clients() takes no protocol argument, so the
link-existing dropdown failed with a TypeError. Route through
_manager_call like every other callsite.
- Add translation keys referenced by templates but absent from all
language files (select_connection, name_required,
no_servers_available) — the UI fell back to raw key names.
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.
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.
Left over from the same handler: 'Invalid role' answered in English next to
_t('user_exists') and _t('password_required_for_role'). New key invalid_role
in all five translation files.
- The init call for toggleNewUserPasswordReq() had landed in the ucProtocol
change handler, which is unrelated to the add-user modal: the label still
said "required" for the default role "none" until something touched that
dropdown. It runs on DOMContentLoaded now.
- api_update_user answered a hard-coded English "Username must not be empty"
next to _t('user_exists'), so a Russian UI got one toast in each language.
New key username_empty in all five translation files.
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)'.
The unittest job installs only requirements.txt (no pytest), so
'unittest discover' failed at import time with ModuleNotFoundError.
Raise unittest.SkipTest on missing deps so the module is skipped
cleanly instead of failing the whole suite.
- Login now rejects role 'none' accounts even if a password hash exists.
- / redirects any non-admin/support role to /my (was: only 'user'),
so a record-only account can never see the admin dashboard markup
with server names/hosts.
- Ownership checks for connection config and user connections now
cover role 'none' the same as 'user'.
- Edit user modal now allows renaming (unique, non-empty; enforced
server-side in /api/users/{id}/update).
- Add user form: password is optional; new default role 'none' — a
record-only user that cannot log in and needs no password. Picking
any real role (user/support/admin) makes the password required,
validated both client-side and server-side.
- Login skips users without a password hash.
- Role badge shows a translated 'No role' label; new translation keys
added for ru/en/fa/fr/zh.
The unittest job installs only requirements.txt (no pytest), so
'unittest discover' failed at import time with ModuleNotFoundError.
Raise unittest.SkipTest on missing deps so the module is skipped
cleanly instead of failing the whole suite.
- Replace the blue/grey circle toggle with a bulb emoji; the off state
is a greyed-out bulb styled in style.css (.peer-toggle-off).
- While the toggle request runs, the bulb becomes a spinner and the
whole client row is dimmed with all its buttons disabled; on error
the row is restored.
- Serve /static with Cache-Control: max-age=15552000, immutable. URLs
already carry ?v=<static mtime>, so a redeploy busts the cache.
The global reduced-motion rule froze every animation, including the
busy indicators, so a running operation looked frozen. Spinners signal
progress, not decoration - exempt them
Upstream merged #134, #136, #137, #141 (wg-easy import, protocol
logos/rename, rename feedback, disabled-IP reservation) and released
1.6.4 with PWA support and Telemt expiry fields. Conflicts were
positional duplicates of already-merged code plus translation keys
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>