115 Commits

Author SHA1 Message Date
Максимов Владимир Сергеевич 3d30d4e3fb Merge PR #176: repair missing AmneziaWG tools 2026-09-14 12:40:34 +03:00
Максимов Владимир Сергеевич 4a3f2f50f8 Merge PR #175: kernel upgrades 2026-09-14 12:40:27 +03:00
Максимов Владимир Сергеевич cb0a5db3b5 Merge PR #174: nonblocking SSH and circuit breaker
# Conflicts:
#	translations/en.json
#	translations/fa.json
#	translations/fr.json
#	translations/ru.json
#	translations/zh.json
2026-09-14 12:40:26 +03:00
Максимов Владимир Сергеевич 0a32145b82 Merge PR #173: WireGuard client configuration 2026-09-14 12:40:19 +03:00
Public relations & Virtual technology 8f1e8a31a0 Merge pull request #165 from leonidorlov-hash/feat/dns6
feat(awg): configurable IPv6 DNS (DNS6) for dual-stack instances
2026-09-14 12:08:39 +03:00
leonidorlov-hash 36a5c15dff feat(awg): self-heal tools/kernel mismatch + sibling staleness warning
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.
2026-09-14 02:09:49 +03:00
leonidorlov-hash 7fde3295ba fix(awg): upgrade an outdated kernel module instead of skipping
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.
2026-09-14 00:49:59 +03:00
leonidorlov-hash 1248ce68c0 feat(servers): configurable SSH circuit-breaker base cooldown
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.
2026-09-13 23:48:10 +03:00
leonidorlov-hash 53f908733c fix(ssh): lower transport keepalive 30s -> 15s
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.
2026-09-13 23:48:10 +03:00
leonidorlov-hash e7740ffbb0 fix(ssh): exponential-backoff circuit breaker per server
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.
2026-09-13 23:44:40 +03:00
leonidorlov-hash 37031761a1 fix(wireguard): get_client_config accepts port; route link flow via _manager_call
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).
2026-09-13 22:39:28 +03:00
leonidorlov-hash b5284b8feb feat(awg): editable IPv6 DNS field in install modal and AWG settings 2026-09-13 14:44:27 +03:00
leonidorlov-hash dbacc3e4a8 Avoid duplicating the IPv6 resolver on config regeneration
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.
2026-09-13 14:27:57 +03:00
leonidorlov-hash 27c6781dad Make the IPv6 DNS configurable instead of hardcoded
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.
2026-09-13 14:27:57 +03:00
leonidorlov-hash 42146681d1 fix(ssh): feed the sudo password via channel stdin, never the command line 2026-09-11 01:01:11 +03:00
hell 95bfd42ff8 fix: four defects in the code that came with #150-#152
- 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.
2026-09-10 00:17:21 +07:00
Public relations & Virtual technology 2c28b886e4 Merge branch 'main' into feat/ui-polish 2026-09-09 12:16:54 +03:00
Public relations & Virtual technology 7b3dcaf822 Merge pull request #149 from helldweller/feat/exit-nodes
feat(exit): route client traffic through another panel server (exit nodes)
2026-09-09 11:34:24 +03:00
Public relations & Virtual technology 00b62493f6 Merge pull request #147 from helldweller/fix/ssh-error-text-upstream
fix(ssh): say what happened when a command dies with the transport
2026-09-09 11:20:35 +03:00
Public relations & Virtual technology 5558a8c7ac Merge pull request #145 from helldweller/fix/host-tuning-upstream
fix(awg): run the host-tuning probe as a privileged script
2026-09-09 11:19:05 +03:00
hell 69893ba340 feat(exit): route client traffic through another panel server
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>
2026-09-08 23:05:51 +07:00
hell 2cb442fe04 fix(ssh): say what happened when a command dies with the transport
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>
2026-09-08 22:25:44 +07:00
hell 3803260905 fix(awg): run the host-tuning probe as a privileged script
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>
2026-09-08 22:17:02 +07:00
hell 04c623cc87 fix: run privileged command chains inside one shell
`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>
2026-09-08 22:07:55 +07:00
Public relations & Virtual technology eb403b1a2e Merge branch 'main' into feat/wgeasy-import-upstream 2026-09-08 13:10:15 +03:00
leonidorlov-hash 22dc4f1042 fix(ipam): tolerate missing clientsTable, fail only on broken container
cat exits non-zero both when docker exec fails and when the table file
does not exist yet (fresh instance). Distinguish via 'docker exec true':
raise only when the container itself is unusable; an absent table simply
means no reservations yet. Fixes the test regression in CI
2026-09-07 21:29:21 +03:00
leonidorlov-hash f836d6d6b2 fix(ipam): tolerate missing clientsTable, fail only on broken container
cat exits non-zero both when docker exec fails and when the table file
does not exist yet (fresh instance). Distinguish via 'docker exec true':
raise only when the container itself is unusable; an absent table simply
means no reservations yet. Fixes the test regression in CI
2026-09-07 21:29:18 +03:00
leonidorlov-hash 728dd5bfac fix(ipam): keep disabled clients' IPs reserved; validate on re-enable
Closes the conflict scenario from #138: disabling a client removed its
[Peer] from the active config while its IP stayed in clientsTable, so
the allocator (which read only the active config) could hand that IP to
a newly created client; re-enabling the original client then activated
two peers with the same address.

AWG (all versions) and WireGuard:
- allocation now counts reservations from clientsTable (disabled clients
  included) in addition to the active config; only deletion frees an IP
- re-enabling validates that the stored IP is not owned by another
  client (table) and not present in the active config; on conflict the
  operation is rejected with a clear error
- if clientsTable cannot be read, allocation fails loudly instead of
  treating the reservation pool as empty

WireGuard additionally derives the subnet from the live server config
instead of the built-in default, so imported instances with custom
subnets allocate correct addresses.

Xray needs no change: clients are identified by UUID, no IP allocation.

Fixes #138
2026-09-07 20:35:19 +03:00
leonidorlov-hash 21a29217ce fix(ipam): keep disabled clients' IPs reserved; validate on re-enable
Closes the conflict scenario from #138: disabling a client removed its
[Peer] from the active config while its IP stayed in clientsTable, so
the allocator (which read only the active config) could hand that IP to
a newly created client; re-enabling the original client then activated
two peers with the same address.

AWG (all versions) and WireGuard:
- allocation now counts reservations from clientsTable (disabled clients
  included) in addition to the active config; only deletion frees an IP
- re-enabling validates that the stored IP is not owned by another
  client (table) and not present in the active config; on conflict the
  operation is rejected with a clear error
- if clientsTable cannot be read, allocation fails loudly instead of
  treating the reservation pool as empty

WireGuard additionally derives the subnet from the live server config
instead of the built-in default, so imported instances with custom
subnets allocate correct addresses.

Xray needs no change: clients are identified by UUID, no IP allocation.

Fixes #138
2026-09-07 20:35:07 +03:00
leonidorlov-hash 85f7517176 fix(import): match source container by web port
With several wg-easy containers on one host, detect_source picked the
first running one - mixing the client list from the requested panel
with the server identity of a different container. Now the container
whose published TCP port matches the authenticated web panel wins
2026-09-06 18:50:01 +03:00
leonidorlov-hash d693140181 fix(import): match source container by web port
With several wg-easy containers on one host, detect_source picked the
first running one - mixing the client list from the requested panel
with the server identity of a different container. Now the container
whose published TCP port matches the authenticated web panel wins
2026-09-06 18:49:20 +03:00
leonidorlov-hash 2ced8dc7ee feat(import): multi-instance import + visible import log
- When AWG 2.0 is already installed on the server, wg-easy import now
  creates the next free instance (awg2__2, amnezia-awg2-2 container)
  instead of failing with a bare 400
- Import progress and errors are shown in a log pane inside the modal;
  the modal no longer closes silently on failure, and stays open on
  success until the user closes it (page reloads to show the instance)
- apiCall keeps the error payload so the UI can render the log
2026-09-06 17:50:14 +03:00
leonidorlov-hash 2a56bc05b8 feat(import): multi-instance import + visible import log
- When AWG 2.0 is already installed on the server, wg-easy import now
  creates the next free instance (awg2__2, amnezia-awg2-2 container)
  instead of failing with a bare 400
- Import progress and errors are shown in a log pane inside the modal;
  the modal no longer closes silently on failure, and stays open on
  success until the user closes it (page reloads to show the instance)
- apiCall keeps the error payload so the UI can render the log
2026-09-06 16:36:15 +03:00
leonidorlov-hash 23933d88d7 feat(wireguard): per-peer bandwidth limits (tc), same as AWG
- iproute2 added to the WG image; older containers get it installed on
  the fly when a limit is applied (no rebuild needed)
- bwlimits flat file + HTB/policer via tc on wg0, re-applied at boot
  by the start script and on every clientsTable save
- set_speed_limit(client_id, max_speed) mirrors the AWG API, so the
  rename/speed popover now offers a limit for WireGuard clients too
2026-09-06 14:19:01 +03:00
Public relations & Virtual technology c3410b9116 Merge pull request #133 from leonidorlov-hash/fix/kmod-failure-reason
fix(awg): report the actual reason when kernel module setup fails
2026-09-05 14:47:01 +03:00
Public relations & Virtual technology d33a4f73ae Merge pull request #132 from leonidorlov-hash/fix/ssh-pool-lock
fix(ssh): serialize command/SFTP execution on pooled connections
2026-09-05 14:46:40 +03:00
leonidorlov-hash 41351067a0 feat(wgeasy-import): auto-target AWG 2.0 when source has obfuscation
w0rng/amnezia-wg-easy sources carry AmneziaWG obfuscation params
(Jc/Jmin/Jmax/S1-S4/H1-H4) in the [Interface] section. Importing such
clients into a plain WireGuard instance would break their configs, so
the importer now reads the source wg0.conf from the container, detects
the obfuscation and installs an AWG 2.0 instance with the exact same
params instead. Plain wg-easy sources still land on WireGuard.

Preview now reports the detected listen port, obfuscation presence and
the resolved target protocol.
2026-09-05 02:55:14 +03:00
leonidorlov-hash eb12bf138b feat: import clients from wg-easy / amnezia-wg-easy panels
Adds an 'Import from wg-easy' button on the server page. The panel
fetches the client list from a running wg-easy (v14 / w0rng fork) or
wg-easy v15 instance through the server's own SSH connection (web API
on 127.0.0.1, nothing needs to be exposed), shows a preview with
selectable clients, and on confirmation:

- stops the old wg-easy container (not deleted)
- installs the panel-managed WireGuard instance on the same UDP port
- restores the original server key, subnet and all client keys/PSKs/IPs,
  so existing client configs keep working unchanged
- writes a backup copy of wg0.json to /root on the server

The WG container start script now accepts a custom subnet so NAT rules
match the imported network instead of the panel default 10.8.2.0/24.
2026-09-05 02:55:14 +03:00
leonidorlov-hash e6f76d5bb2 feat(awg): report the actual reason when kernel module setup fails
The DKMS script used 'set -e' and surfaced a bare 'build failed' on any
error. Now each step (headers install, repo clone, dkms add/build/
install, modprobe) reports a specific reason, e.g. 'headers for the
running kernel 6.1.0-9-amd64 are no longer in the repos - upgrade the
kernel and reboot', and the reason is shown in the install log.
2026-09-05 02:49:04 +03:00
leonidorlov-hash fb8d5f9ac3 feat(awg): report the actual reason when kernel module setup fails
The DKMS script used 'set -e' and surfaced a bare 'build failed' on any
error. Now each step (headers install, repo clone, dkms add/build/
install, modprobe) reports a specific reason, e.g. 'headers for the
running kernel 6.1.0-9-amd64 are no longer in the repos - upgrade the
kernel and reboot', and the reason is shown in the install log.
2026-09-05 02:46:10 +03:00
leonidorlov-hash fe3793749c feat(wgeasy-import): auto-target AWG 2.0 when source has obfuscation
w0rng/amnezia-wg-easy sources carry AmneziaWG obfuscation params
(Jc/Jmin/Jmax/S1-S4/H1-H4) in the [Interface] section. Importing such
clients into a plain WireGuard instance would break their configs, so
the importer now reads the source wg0.conf from the container, detects
the obfuscation and installs an AWG 2.0 instance with the exact same
params instead. Plain wg-easy sources still land on WireGuard.

Preview now reports the detected listen port, obfuscation presence and
the resolved target protocol.
2026-09-05 02:32:21 +03:00
leonidorlov-hash 19f3bb1d66 feat: import clients from wg-easy / amnezia-wg-easy panels
Adds an 'Import from wg-easy' button on the server page. The panel
fetches the client list from a running wg-easy (v14 / w0rng fork) or
wg-easy v15 instance through the server's own SSH connection (web API
on 127.0.0.1, nothing needs to be exposed), shows a preview with
selectable clients, and on confirmation:

- stops the old wg-easy container (not deleted)
- installs the panel-managed WireGuard instance on the same UDP port
- restores the original server key, subnet and all client keys/PSKs/IPs,
  so existing client configs keep working unchanged
- writes a backup copy of wg0.json to /root on the server

The WG container start script now accepts a custom subnet so NAT rules
match the imported network instead of the panel default 10.8.2.0/24.
2026-09-05 01:29:56 +03:00
leonidorlov-hash 0fed74d995 Merge fix/docker-ipv6-no-daemon-restart + fix/ssh-pool-lock into deploy/v160 2026-09-05 00:22:22 +03:00
leonidorlov-hash e9536a775a fix(ssh): serialize command/SFTP execution on pooled connections (#125)
The pooled SSHManager is shared between request handlers and background
threads (traffic sync, conn monitor). When one side's exec failed, its
reconnect closed the shared client while the other side was mid-command:
'NoneType open_session', EOFError in open_sftp, a burst of 500s, then
30s of fast-fail from the connect cooldown.

Add an RLock around exec_command and all SFTP operations (RLock because
run_command retries by re-entering itself). Lock ordering is
exec -> conn, connect/disconnect never take the exec lock, so no
deadlock. Failed execs now serialize the reconnect too: other threads
simply wait and then run on the fresh transport.
2026-09-05 00:13:43 +03:00
leonidorlov-hash 50e6288b17 fix(awg): never restart Docker daemon implicitly when enabling IPv6 (#124)
prepare_host() merged ipv6/ip6tables keys into /etc/docker/daemon.json and
ran 'systemctl restart docker' mid-install. That killed every container
on the host (running tunnels dropped ~20s) and, when the panel itself
runs in Docker on the managed host, killed the panel together with its
own install request.

Now the daemon restart happens only when daemon.json actually changed
AND no amnezia-* or panel containers are running. Otherwise the restart
is deferred and the install log warns the admin that tunnels stay
IPv4-only until 'systemctl restart docker' is run manually. A successful
restart is also logged.
2026-09-04 22:45:53 +03:00
Public relations & Virtual technology 8a8d8f8309 Merge pull request #128 from vikbaranov/feature/per_server_restore
feat: add ability to restore, upload, delete server backups
2026-09-04 16:32:51 +03:00
Public relations & Virtual technology f8ecd5221e Merge pull request #127 from leonidorlov-hash/feat/awg-kernel-module-dkms
feat(awg): install amneziawg kernel module (DKMS) on protocol install
2026-09-04 16:16:32 +03:00
Public relations & Virtual technology 0863361cf6 Merge pull request #126 from leonidorlov-hash/fix/xray-hybla-cc
fix(xray): set bbr instead of hybla in container sysctl tuning
2026-09-04 16:15:53 +03:00
Public relations & Virtual technology e0a33b895b Merge pull request #123 from helldweller/refactor/awg-builders
refactor(awg): builders for the Dockerfile, docker run and start script
2026-09-04 16:15:35 +03:00
Viktor Baranov 3770870e32 feat: add ability to restore server backups 2026-09-04 13:39:56 +03:00