47 Commits

Author SHA1 Message Date
Максимов Владимир Сергеевич e7d7a769fc test: guard cross-PR integration and all-locale rendered JavaScript 2026-09-14 12:45:53 +03:00
Максимов Владимир Сергеевич 3d30d4e3fb Merge PR #176: repair missing AmneziaWG tools 2026-09-14 12:40:34 +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 a0e15af3ac Merge pull request #163 from leonidorlov-hash/fix/sudo-password-stdin
fix(ssh): feed the sudo password via channel stdin, never the command line
2026-09-14 12:07:00 +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 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 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 42146681d1 fix(ssh): feed the sudo password via channel stdin, never the command line 2026-09-11 01:01:11 +03:00
hell 539ef92197 test: cover the exit-node paths these two suites already touch
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.
2026-09-11 01:39:13 +07:00
hell 5a06a713a1 fix(api): register the protocol rename route once
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).
2026-09-10 00:17:22 +07:00
hell 9ff488de42 fix: three follow-ups from the second review round
- 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.
2026-09-10 00:17:21 +07: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
hell a4d68d424e fix: repair main after the exit-node and UI merges
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)'.
2026-09-10 00:17:20 +07: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 a716df1cee Merge pull request #148 from helldweller/fix/json-errors-upstream
fix(api): answer JSON on unhandled errors, and stop parsing blind on the client
2026-09-09 11:29:36 +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 cf3d0cd2bd Merge pull request #146 from helldweller/test/template-js-upstream
test: parse the inline template JavaScript and the translation files
2026-09-09 11:19:43 +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
Public relations & Virtual technology 21b0d54bc2 Merge pull request #144 from helldweller/fix/v164-defects
fix: five defects in the v1.6.4 rename, import, PWA and test code
2026-09-09 11:18:20 +03:00
Public relations & Virtual technology 411abcbb20 Merge pull request #143 from helldweller/fix/sudo-chains-upstream
fix: run privileged command chains inside one shell
2026-09-09 11:17:45 +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 cf25956a9f fix(api): answer JSON on unhandled errors, and stop parsing blind on the client
"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>
2026-09-08 22:27:57 +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 ed08fc0f70 fix: two more v1.6.4 defects found in review
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>
2026-09-08 22:23:09 +07:00
hell 119ec9edfa test: parse the inline template JavaScript and the translation files
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>
2026-09-08 22:18:19 +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
hell e537823184 fix(tests): skip the Playwright e2e module when pytest is missing
tests/test_playwright_e2e.py starts with a bare `import pytest`, but pytest is
not in requirements.txt - only playwright is. CI installs requirements.txt and
runs `python -m unittest discover -s tests`, which imports every module under
tests/, so collection aborts on that import and the whole suite fails.

`main` has been red since the module landed: the runs for #134, the v1.6.4 tag
and the follow-up commit all report `FAILED (errors=1)` with
`ModuleNotFoundError: No module named 'pytest'`, and none of the 152 real
unit tests are reported.

The import is now guarded and raises unittest.SkipTest, so `unittest discover`
skips the module and keeps running the rest. Nothing changes for pytest users:
with pytest installed the import succeeds and `pytest tests/test_playwright_e2e.py`
behaves exactly as before. The module docstring now also says it expects a
panel already serving on BASE_URL.

An alternative would be adding pytest to requirements.txt, but that pulls a
test runner into the runtime dependencies of the panel, and `unittest discover`
still would not run these tests - they are pytest fixtures and plain classes,
not unittest.TestCase.

Before: Ran 152 tests ... FAILED (errors=1)
After:  Ran 152 tests ... OK (skipped=1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:05:33 +07:00
Максимов Владимир Сергеевич 91c8d7ad45 v1.6.4 2026-09-08 13:48:41 +03:00
Viktor Baranov 30156c5829 feat(ui): design system and mobile layout overhaul 2026-09-05 16:55:33 +03:00
Public relations & Virtual technology fce3d49ef1 Merge pull request #129 from vikbaranov/fix/telegram_admin_connection_creation
fix(telegram): allow admins to create connection in telegram
2026-09-04 16:33:52 +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 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
Public relations & Virtual technology d2d7d63235 Merge pull request #122 from helldweller/feat/server-uid
feat(servers): stable uid on every server record
2026-09-04 16:14:18 +03:00
Viktor Baranov fd8dc6f083 fix(telegram): allow admins to create connection in telegram 2026-09-04 14:12:13 +03:00
Viktor Baranov 3770870e32 feat: add ability to restore server backups 2026-09-04 13:39:56 +03:00
hell 25f9d2c965 refactor(awg): builders for the Dockerfile, docker run and start script
The start script of an AWG container was spelled out twice, byte for
byte: in _upload_start_script() (install) and inline in
save_server_config() (raw config editor). Any change to forwarding, NAT
or the userspace guard had to be made in both places, and nothing
checked that they stayed identical.

Extract three pure builders - _dockerfile_content(), _docker_run_cmd()
and _start_script_body() - plus _render_start_script() and
_write_start_script() (upload + docker cp + chmod, no restart) on top of
them. _upload_start_script() is now write + restart and loses the port
and awg_params parameters it never used; save_server_config() calls
_write_start_script() with the resolved config path as before.

The builders are pinned by fixtures captured from the previous code by
running install_protocol()/save_server_config() against a fake SSH, so
the generated Dockerfile and start.sh are proven identical for awg,
awg3 and awg_legacy. The one intentional difference: docker run emitted
`--name <container>` twice; it is emitted once now and the test spells
out that delta against the old fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 01:11:07 +07:00
hell 155c29658e feat(servers): stable uid on every server record
Servers are addressed by their position in data['servers']: reorder
rebuilds the list and remaps user_connections, delete pops an entry and
shifts the ones after it. Any new cross-server reference (a server that
routes through another one) keyed by that index would silently point at
the wrong host after either operation.

Give every server record a `uid` (uuid4 hex). It is minted only in write
paths - the startup migration, api_add_server and the data.json restore
endpoint - never inside load_data(), because two concurrent readers
would otherwise assign different ids to the same record and the last
writer would win. find_server_by_uid() resolves a uid back to (index,
record) and treats an empty or unknown uid as not found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 00:57:59 +07:00
hell 3d02d2ab38 fix(awg): invalidate the server-config cache after rewriting the config
AWGManager caches the server config for 10 s (_server_config_cache),
but only save_server_config() drops that cache before writing. The other
writers - _insert_peer_sorted, both branches of toggle_client,
remove_client and _write_server_config - rewrite awg0.conf in the
container and leave the stale copy in place, so a second read on the
same manager instance within the TTL returns the pre-write content:
_get_next_ip hands out the address it just assigned, and a peer removed
and re-added through one instance is resurrected from the cached text.

Today every API call builds a fresh manager, so the window is confined
to a single request; the upcoming exit-node work (remove + re-add of a
transit peer in one operation) runs straight into it. Add
_invalidate_config_cache() and call it after each of the five writes,
with a test that drives the real methods against an in-memory container
filesystem and fails on the unpatched code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 00:57:59 +07:00
Viktor Baranov c981c775ff feat: flexible self-service protocols 2026-09-02 15:35:46 +03:00
Viktor Baranov c1c0bc9d25 fix: stuck "loading connections" on my page 2026-09-01 22:45:50 +03:00
Viktor Baranov 581ad0dbd0 fix: address self-service review findings 2026-08-26 21:52:00 +03:00
Viktor Baranov 34bfdbb3b4 feat: add Telegram slash command suggestions 2026-08-25 14:24:45 +03:00
Viktor Baranov 7cbb08aa36 fix: localize Telegram bot menus 2026-08-25 13:55:41 +03:00
Viktor Baranov 0f91bed479 feat: add ConnectionService and self-service tests 2026-08-25 08:48:18 +03:00