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.
One hung server (15s connect x retries) or the auto-backup sweep used to
freeze the whole panel for minutes, local endpoints included.
- 66 endpoints that only do blocking work converted from async def to
def so Starlette runs them in the threadpool
- mixed endpoints (exit-link awaits, file uploads, DATA_LOCK sections)
stay async; their blocking SSH calls now go through asyncio.to_thread
- get_ssh() (which blocks in ensure_connected) is no longer called from
async code in app.py, ExitLinkService or ConnectionService
- background tasks were already threaded/to_thread; DATA_LOCK and
TUNNEL_LOCK semantics untouched
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.
Saving the peer editor (rename / speed / user link or unlink) triggered
a full blocking 'Loading connections...' overlay because it called
loadConnections() in foreground mode. The refresh now goes through the
poll path (silent) with a force flag that bypasses the
rename-editor-open guard only for this save flow.
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 poll already in flight when the rename editor opened returned and
re-rendered the list, destroying the editor on first open (later
polls are skipped, so a second open worked). Re-check for an open
editor right before rendering poll results.
- toggleConnection always re-rendered the whole list after a toggle,
except when a poll was in flight (the connLoading gate silently
skipped it) — inconsistent UI. The row is now updated in place
(opacity, bulb state, tooltip, stop badge, onclick); the 5s poll
reconciles speeds and badges.
- Rename/save toast appeared before loadConnections() closed the
editor; the list now re-renders first, toast after.
- Bulb tooltip used the state key ('Stopped'); added
stop_peer_title/start_peer_title action keys in all 5 locales.
- The user select defaulted to the first no-role user, so saving a
rename silently linked the peer. '-- do not link --' now comes first
and is selected unless the peer is already linked.
- 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)'.