504 Commits
Author SHA1 Message Date
leonidorlov-hash fbff1d093a Users page: bulb toggle for users, live peer names, deep links to peers
- 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.
2026-09-13 20:15:13 +03:00
leonidorlov-hash 026714dc0e Rich connection cards in the /users connections modal
/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.
2026-09-13 20:15:13 +03:00
leonidorlov-hash 6ccff31436 feat(users): multi-select linking, remembered options, no full page reload
- 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.
2026-09-13 20:05:38 +03:00
leonidorlov-hash 110b3e4882 feat(users): unlink-without-delete action + deep links to peers
- 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
2026-09-13 19:54:56 +03:00
leonidorlov-hash 155a0673dc fix(users): WireGuard existing-clients list + missing i18n keys
- 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.
2026-09-13 15:59:32 +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 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
Public relations & Virtual technology dbccc1ec40 Merge pull request #157 from helldweller/fix/red-main-upstream
Fix v1.6.5: server page dies on a duplicated script block, plus six defects from the same merges
v1.6.6
2026-09-09 21:31:57 +03:00
hell 828425ebc9 fix(users): localise the invalid-role error too
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.
2026-09-10 00:17:23 +07:00
hell 2ed0bc6ab5 fix(users): initialise the password label, and localise the empty-username error
- 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.
2026-09-10 00:17:22 +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 c39ea0c9ce Update app.py v1.6.5 2026-09-09 14:11:07 +03:00
Public relations & Virtual technology 5b62d5385b Merge pull request #152 from leonidorlov-hash/feat/users-norole
Users: editable username, optional password with 'No role' default + hardening
2026-09-09 12:18:14 +03:00
Public relations & Virtual technology f66ba9b71d Merge branch 'main' into feat/users-norole 2026-09-09 12:18:05 +03:00
Public relations & Virtual technology 9c0f28c52d Merge pull request #150 from leonidorlov-hash/feat/ui-polish
UI polish: peer badge for speed limits, spinner animation fix, client-actions wrap
2026-09-09 12:17:09 +03: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 d7e3a4dd09 Merge pull request #151 from leonidorlov-hash/feat/local-vendor
Vendor CodeMirror and ReDoc locally (drop public CDN dependency)
2026-09-09 12:15:38 +03:00
Public relations & Virtual technology 02ca2ac8fd Merge branch 'main' into feat/local-vendor 2026-09-09 12:15:28 +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 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
Public relations & Virtual technology c974e3ba4f Merge pull request #142 from helldweller/fix/e2e-import-guard
fix(tests): skip the Playwright e2e module when pytest is missing
2026-09-09 11:16:57 +03:00
leonidorlov-hash 95626777b6 CI: skip Playwright E2E module when pytest is not installed
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.
2026-09-08 23:17:17 +03:00
leonidorlov-hash 60fd228ef9 Harden role 'none': block login, tighten page and ownership guards
- 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'.
2026-09-08 23:17:09 +03:00
leonidorlov-hash 54e735953b Users: editable username, optional password with 'no role' default
- 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.
2026-09-08 23:17:09 +03:00
leonidorlov-hash a912e87df5 CI: skip Playwright E2E module when pytest is not installed 2026-09-08 22:45:37 +03:00
leonidorlov-hash 0eb8713f8d Serve CodeMirror and ReDoc from /static/vendor instead of public CDNs 2026-09-08 22:42:17 +03:00
leonidorlov-hash f8e4b95202 traffic-limit class on the speed badge (both limited and unlimited) 2026-09-08 22:41:39 +03:00
leonidorlov-hash 976ed739f0 CI: skip Playwright E2E module when pytest is not installed
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.
2026-09-08 22:16:53 +03:00
leonidorlov-hash 5fa0da6b67 Match button spinner accent color to the save-spinner (#1c1c30) 2026-09-08 22:13:20 +03:00
leonidorlov-hash af88df3bb9 Bulb peer toggle with in-flight spinner + 180-day static cache
- 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.
2026-09-08 21:50:49 +03:00
leonidorlov-hash 54aca41012 Show 🐢 badge for peers with a speed limit (was ⚡ for both states)
Unlimited peers keep ⚡∞; limited peers now show 🐢 X Mbps so the
limited state is visually distinguishable at a glance.
2026-09-08 21:03:30 +03:00
leonidorlov-hash 49b06970bd fix(css): keep spinners rotating under prefers-reduced-motion
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
2026-09-08 20:58:42 +03:00
leonidorlov-hash bcff303120 style: drop the 140px cap on .client-actions
Action buttons may wrap freely instead of being squeezed into a fixed
narrow column
2026-09-08 20:54:00 +03:00
leonidorlov-hash ae73f0f1ed Merge upstream v1.6.4 into deploy/v160
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
2026-09-08 20:23:06 +03:00
hellandClaude Opus 5 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
hellandClaude Opus 5 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
hellandClaude Opus 5 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
hellandClaude Opus 5 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
hellandClaude Opus 5 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