The official Amnezia client installs AmneziaWG 3.x into the amnezia-awg2
container; only HeaderProtectionKey in the config tells it from 2.0. Flag
it on /check and name the card/connection accordingly, keeping the
protocol key and container untouched.
Closes#191
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
add_client() and edit_client() referenced config_content, which only
exists in install_protocol(), raising NameError: name 'config_content'
is not defined on every telemt client create/update (issue #109).
Save the locally built config_text instead, and cover both paths with
regression tests.
force_disconnect() (pool eviction) only took _conn_lock while exec took
_exec_lock, so an eviction could null self.client between
ensure_connected() and exec_command() -> 'NoneType' object has no
attribute 'open_session', with the reconnect retry losing the same race
again. _conn_lock is now an RLock held for the entire command/SFTP
operation, so eviction waits for in-flight work. Regression test blocks a
fake exec and asserts the evictor stays parked until it finishes.
A truncated/corrupt clientsTable JSON (flaky SSH, channel drop mid-read)
used to hit 'except JSONDecodeError: return []' and the UI rendered
'Connections: 0' for a server that has peers. Now a corrupt prefetched
batch entry is dropped and re-read directly; a corrupt direct read raises
so get_server_status reports an error and the UI shows no number instead
of lying.
- ssh_manager: docker_ps_snapshot() caches 'docker ps -a' (TTL 10s),
docker_container_state() answers existence/running from the snapshot
- awg_manager: prefetch_awg_state() fetches awg0.conf + clientsTable of
ALL running AWG containers in ONE ssh command (TTL 15s); check/status
paths read from the batch
- other managers use docker_container_state with graceful fallback
- caches invalidated on container install/remove/toggle and on
clientsTable save
- /check: RAHMET ~19 commands -> ~4, FINN ~31 -> ~6
AWG containers keep /opt/amnezia/awg (peers, keys, clientsTable) inside the
container - there is no bind mount, so the 'Remove old container' step of a
reinstall destroyed every peer irrecoverably (incident: a stale status made
the UI offer plain Install over a live AWG 3.1 instance; the reinstall path
rm -fv'd the production container). Now install_protocol snapshots the state
dir to /opt/amnezia/backups/<container>-<timestamp> on the host via docker cp
before touching the container. Backup failure warns in the install log but
never blocks the install.
AWG_DEFAULTS['mtu'] is a fixed 1376 for every protocol generation, but
AWG 3.x appends a random ContentPaddingAddition to every data packet on
top of S4. With the parameters this panel generates, a fresh 3.x install
produces worst-case packets of 1504 bytes against a plain 1500-byte link.
ContentPaddingAddition is a range, so only the packets drawing its upper
end are oversized: handshakes succeed and small requests work while bulk
transfers stall, which reads as "flaky with some sites" rather than as a
broken tunnel.
Derive the default instead. transport_overhead() counts what obfuscation
costs per data packet (60-byte header stack + S4 + the top of
ContentPaddingAddition; handshake sizes and junk packets travel in their
own packets and are excluded). safe_client_mtu() turns that into the
largest MTU whose worst case still fits, floored at 1280 per RFC 8200.
install_protocol uses it when no MTU was passed; an explicit value from
the UI still wins, since operators behind a provider that tunnels their
traffic need to go lower still. The AWG settings modal now warns when the
entered MTU cannot fit a standard link, and the install modal no longer
hardcodes 1376.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel is absolutely positioned inside .modal, which is a scroll box
(max-height: 90vh; overflow-y: auto) with a transform on it, inside a
backdrop that carries a backdrop-filter. Such a box clips the panel at its
own edge, and because the transform also makes the modal the containing
block for fixed children, position: fixed does not get out either. Add
Connection with a long user list showed the option list cut off mid-row.
While open, the panel now moves to <body>, where searchable-select.js
places it in viewport coordinates: under the trigger, flipped above it when
there is no room below, with the option list shrunk to fit and the panel
clamped into the viewport. On close it goes back under its own trigger and
drops the coordinates.
Leaving the wrapper costs a few things that used to come for free, so:
- the outside-click check ignores the panel, otherwise clicking the search
box closed the dropdown straight away;
- scroll (capture phase, so a scroll inside the modal is seen) and resize
re-anchor the panel; a scroll inside the option list is ignored, and the
list keeps its scroll position across a reposition;
- the panel closes with the modal it belongs to - a closed modal keeps its
layout box, so that is an ancestor MutationObserver plus a real
visibility check - and when the trigger scrolls out of view;
- Tab hands focus back to the trigger, so the next tab stop is the field
after it and not whatever follows in <body>.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel could already show a user their configuration, put it behind a
share link or push it through the Telegram bot. Email was the one channel
missing, and the one most admins fall back to a hand-written script for.
Adds an SMTP block to /settings (host, port, none/STARTTLS/SSL, credentials,
sender, Reply-To, timeout) with a one-click test message, a "send by email"
action on every user card, and a mass send from the Users toolbar.
What travels: AmneziaWG / WireGuard peers as .conf attachments plus the
vpn:// key, Xray and Telemt as links -- their config *is* the link, so a
.conf would be a file no client can import. Files and links are independent
switches. The config text comes from the same manager call the share page
and the bot already use, so a protocol that renders in the UI renders in
the mail.
The mass send runs in a worker thread over a single SMTP session (a fresh
connection per message is what gets a sending account throttled) and the
dialog polls its progress: sent, skipped -- no address, disabled, no
connections yet -- and failed, each with the reason next to the name. One
unreachable node costs that one connection, not the message and not the run.
Details worth knowing:
- The stored SMTP password is never echoed back to the page; an empty field
on save keeps it, a blank one clears it.
- Messages are text plus an HTML alternative styled with the panel's own
light-theme tokens and appearance title/logo -- no third-party branding.
- A multi-line subject is folded instead of raising: EmailMessage refuses
linefeeds in headers.
- If the SMTP settings go bad between the click and the worker thread, the
job finishes with an error rather than staying "running" forever and
blocking every later run with a 409.
tests/test_mail_service.py covers the settings merge, the transport
(STARTTLS, login, reconnect after a dropped session), message shape and
attachment naming, the single send and every branch of the bulk counters,
plus the API surface (auth, password round-trip, translated errors).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every config and link the panel issues carried the address it opens SSH
to. That is not always where clients can reach the instance: a second IP
on the same box, a port forward, a domain name. Telemt made it worse --
it prints its own tg:// links, and install rewrote public_host in
config.toml with the panel's address, so a manually bound instance
handed out links to the wrong endpoint after every reinstall.
An instance record now carries optional public_host/public_port, set
from the globe button on its card or via
POST /api/servers/{id}/protocol/public-endpoint. The address flows into
AmneziaWG/WireGuard Endpoints, vless:// and tg:// links, connections
created for a user and self-service provisioning. Unset means exactly
what it meant before: the server address and the instance's listen port.
- telemt: the address is written into config.toml and reloaded without a
restart; saving with the box down keeps the record and warns
- telemt: _patch_public_links matches the keys line by line -- the old
pattern could span lines and weld public_host onto the line above
- xray: an explicit public port wins, but meta.json (synced from the
live server.json) still beats the panel's remembered port
- reinstalling an instance keeps the address, like its exit link
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On flaky links the SSH channel dies seconds into the build while the docker
daemon keeps building to success - a synchronous wait reported a false
failure (observed on EUROBYTE: channel closed 2s in, image built fine).
Start the build under nohup with the exit code written to a file and poll
that file; every poll is a fresh short channel, so transport drops can no
longer falsify the build result. Build log stays on the server for
post-mortem; failures return the log tail.
BuildKit progress (megabytes of stderr) no longer streams through the SSH
channel mid-build: output goes to /tmp/docker-build-<name>.log on the server
and only the last 6000 bytes come back. This removes mid-build stalls/deaths
on flaky links and guarantees the real error text survives.
Frontend: clearInterval on the error path (progress lines no longer appear
after the failure) and render the error via textContent so log content can
never be swallowed by HTML parsing.
Slow registry pulls (flaky routes) exceeded the 300s build timeout; the
killed build left a mid-download stderr fragment - or nothing - as the
'error'. The build now gets 900s, and a timeout-killed build reports
the timeout explicitly with the stdout tail instead of an empty error.
The connections list used to poll every 5s unconditionally. Each poll is a
full connections read over SSH - fine on fast servers, heavy on slow/flaky
ones. The management modal now offers a per-server interval selector:
0 (off, default), 5, 10, 15, 20, 45, 120, 300, 600 seconds. Stored as
server['peer_poll_interval'], validated server-side against the step list,
i18n for all five locales.
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.