23 Commits
Author SHA1 Message Date
maeneko a2d1be2ed6 Release 0.5.3
ci / check (push) Canceled after 0s
release / release (push) Canceled after 0s
2026-10-30 00:30:37 +03:00
maeneko af357a0a6f Prometheus on a node with its own panel: scrape core through the agent
The installer pointed it at the node's own panel and left AGENT_METRICS_PORT
unset, so the agent refused /prom/… and core never saw it. A node's own panel
now ignores the local Prometheus and shows no install buttons.
2026-10-30 00:30:31 +03:00
maeneko 86dabfe3a0 Release 0.5.2
ci / check (push) Canceled after 2m33s
release / release (push) Canceled after 0s
2026-10-30 00:16:11 +03:00
maeneko 6fa54041f6 Prometheus install: wait until the panel sees it before stopping the poll
The status handler probed Prometheus before reading the job, so a poll
could get exit 0 with ready=false; the page then stopped polling and the
Monitor tab waited for App's 60 s check.
2026-10-30 00:16:11 +03:00
maeneko 33dd488971 Release 0.5.1
ci / check (push) Canceled after 14m19s
release / release (push) Canceled after 0s
2026-10-29 23:52:26 +03:00
maeneko 27f4a9191a Send the token with /ui/brand from the settings tab
The route has been behind auth since aa848de, but the settings tab still
called it without a token: the 401 "Unauthorized" made the panel treat
the session as expired and log out on every visit to Settings. The mock
now requires auth on /ui/brand too, like the server.
2026-10-29 23:51:21 +03:00
maeneko 1ea2eda04a Keep the session on network errors when restoring it
Only a 401 clears the saved token; a network error or 5xx while the
panel restarts retries every 5 s instead of dropping to the login screen.
2026-10-29 23:49:49 +03:00
maeneko 2b2ebdcfd0 Installer: ALLOW_048=y updates a 0.4.8 install without the wipe
ci / check (push) Failing after 11m57s
release / release (push) Successful in 19m38s
0.4.8 data migrates to 0.5.0, so the outdated-install gate can be skipped for
it on request. The red card mentions the flag when the old version is 0.4.8;
awg-ctrl passes its environment through, so ALLOW_048=y awg-ctrl works too.
2026-10-29 23:11:30 +03:00
maeneko 67204ddc25 Merge origin/main into the 0.5.0 redesign
ci / check (push) Failing after 6m27s
Brings in 0.4.8: connection log, uninstall and update from the awg-ctrl menu,
secret panel path, stealth hub and sen:// port, random I1/MTU on install.
install.sh keeps the new output (spinner, cards, outdated-install wipe) on top
of the reorganised installer; an outdated install can be reinstalled with a
wipe or removed. The connection log tab uses the MD3 segmented button; its
metrics-token scrape snippet moved to Settings.
2026-10-29 23:07:03 +03:00
maeneko 5b29993d5d Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output
ci / check (push) Successful in 23m54s
- UI: settings tab with sections (server, API keys, Prometheus, audit, migrate,
  about); server settings moved there; master keys table rework (menu, chips,
  version, compact phone cards); three-state node version chip
- I1: random per-server template (DNS / QUIC Initial / STUN) kept in users.db,
  GET/POST /api/masking, change from the master keys banner
- Hub rejects nodes older than 0.5.0 (close 4026), /ui/nodes reports outdated
- install.sh: spinner for long commands (full output in the log), step progress
  bar, cards; installs older than 0.5.0 require a full reinstall with data reset
- Bump to Beta 0.5.0
2026-10-29 22:59:09 +03:00
Ivan 17bd6b19a4 Merge pull request #1 from maeneko/claude/latest-release-changes-i2p2c0
Beta 0.4.8: защита от обнаружения, журнал подключений, установщик и CLI
2026-10-28 16:00:09 +03:00
Claude f0949c9136 Bump to Beta 0.4.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:57:21 +00:00
Claude 0a9eaa8c8e awg-ctrl: commands for the secret path, connection log, metrics, hub, check
The new features were only reachable from the web panel. awg-ctrl now has:

- panel [path new|off|<path>] — the panel address; a new random, own or no
  secret path (cli.env, panel restarted, VPN untouched);
- log [<search>] [-n N] and log seen — the connection log of this server and
  who was online when, with sen:// device and master key names from ui.db when
  the panel is on the same machine;
- metrics [token|revoke] — the metrics-only token for an external Prometheus,
  printed once with a ready prometheus.yml fragment that carries the path;
- hub [legacy on|off|auto] — nodes with and without the pre-upgrade signature
  and the HUB_LEGACY mode;
- check — what this server gives away: shared pre-randomization VPN values or
  no own I1, no secret path, plain-HTTP panel and subscription, the generic
  "sen"/"awg-node" certificate names, an open hub — each with the fix.

The interactive menu gets items 10–13: panel address and path, connection log,
who was online, and the check. Columns are padded by runes, not bytes, so
Cyrillic names line up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:55:39 +00:00
Claude ea07914249 awg-ctrl: update, reinstall and uninstall from the menu
The awg-ctrl menu only started, stopped and changed the panel login; updating
or removing an installation meant finding and running install.sh by hand. The
menu gets "Обновить", "Переустановить" and "Удалить" (7–9), and the same as
`awg-ctrl update | reinstall | uninstall`.

The work stays in install.sh, which now takes ACTION=update|reinstall|uninstall
to skip its own menu. The CLI fetches it — the latest from INSTALLER_URL
(GitHub by default) for update and reinstall, the copy shipped in the release
archive for uninstall, so removal works offline — writes it to a temp file and
execs bash on it in the same terminal. It must not run from /opt/awg-control or
via `bash -c`: kill_related kills every process whose command line holds the
install path, and would kill the installer itself. The CLI passes its original
environment, not the one with cli.env loaded, or the installer would take
UI_PORT, SERVER_NAME and the rest as explicit overrides. The installer deletes
its temp copy on start.

The release archive now includes install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:50:00 +00:00
Claude 1169a0adc7 Tidy up the installer
Layout: the file now reads top to bottom as output helpers, constants,
functions and the scenario. Functions were scattered between executable code
(some defined inside steps), the previous role was computed twice, and the
firewall code sat in the middle of a step. The firewall is now a function and
a step of its own; installing the awg-ctrl command moved into the services
step, so there are still six steps.

Fixes:
- the shebang was on line 4, after the license comment, so it did nothing;
- a reinstall removed /opt/awg-control before the configuration questions, so
  answering "no" to "Continue?" left the server with no installation; it is
  now removed only after the confirmation;
- with kept data the panel port was asked again with a new random default,
  which broke bookmarks and the API address even though the secret path was
  kept; the panel port, login, password and JWT secret now default to the
  previous values;
- UI_PORT, UI_USER, UI_PASS and SERVER_NAME from the environment were
  overwritten by the prompts; they now skip them, as the README says;
- when the user database existed but was not kept, the server name was not
  asked and silently became "VPN";
- random ports are checked to be free and distinct from each other;
- the server IP from ifconfig.me is validated, with a fallback to the source
  address of the default route;
- prompts are all in Russian; a panel with VPN can pick a random node port
  with "+";
- comments no longer point to docs/node-protocol.md, which is not in the repo;
- shellcheck -S warning is clean.

An update now gives a secret path to an install that has none and prints the
new panel address (UI_PREFIX=off, or an empty UI_PREFIX= in cli.env, keeps it
without one). The summary shows whether the panel has a secret path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:27:08 +00:00
Claude ff2d71600c Rewrite the README
Sections were added one feature at a time and ended up in arrival order,
with the stealth notes inside "Installation". The README now has a feature
summary and contents, an architecture section with the ports each server
opens, installation with roles and a table of installer variables, update /
reinstall / uninstall, a section on several servers and moving the panel
(it pointed to docs/node-protocol.md, which is not in the repo), one
"protection against detection" section covering randomized parameters, the
quiet ports and what they do not cover, the connection log, monitoring,
nginx, the API with the secret path in the base URL and the new connlog and
metrics routes, CLI commands and the main cli.env settings.

Also fixed on the way: the subscription is HTTPS by default, and a plain
update does not add a secret path to an older install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:13:40 +00:00
Claude 1a54d05cca VPN connection log: who connected and when
WireGuard has no connect events, only the last handshake and byte counters.
awg-ctrl now checks them every 30 s and writes conn_log in users.db: connect
at the first handshake, disconnect at the last activity (the last tick the
counters moved), with the session length and traffic, plus peer_seen, the
last time each peer was online. Open sessions are restored from the database,
so a restart adds no duplicates; a down interface is not a disconnect; a peer
removed mid-session is closed. Events keep the name, owner and VPN IP as of
that moment and live 90 days. Client public IPs are not recorded.

GET /api/connlog serves the events and the per-peer last-seen list. The panel
fetches it from the selected server at /ui/connlog and fills in master key
labels and device names for sen:// peers. A new "Подключения" tab shows the
event feed with search and paging, and "who was online when", including
those who never connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:06:40 +00:00
Claude fd5e2cc761 Hub: node signature before the WebSocket upgrade
The hub accepted a WebSocket from anyone on /node/v1, a path in the open
code, and replied with a hello nonce: one connection was enough to tell a
scanner what it had found. The agent now signs the upgrade request itself
(X-Node-Id, X-Node-Ts, X-Node-Sig; on first binding also the join secret
and its key), and the hub checks it before Accept. Without a valid
signature it answers Go's plain "404 page not found". Inside the socket
the protocol is unchanged, and the auth frame must come from the node that
signed the request.

Older agents send no signature. The hub lets them in while some node has
never connected the new way (nodes.preauth), logs them as outdated, and
closes that door by itself once every node is updated; HUB_LEGACY=on/off
overrides. The timestamp window is a day: node clocks drift, and the
headers travel inside pinned TLS. The agent treats a 404 at upgrade as a
rejection and backs off for a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:58:06 +00:00
Claude a3d4664214 Stealth sen:// port, random cert names, external Prometheus card
The subscription port answered every request with signed JSON: one probe
told a scanner what it had found, and the public key from any sen:// link
pinned the exact server. It now speaks the protocol only to those who prove
a secret — peek and register with the link's master secret, everything else
with a valid device signature — and gives Go's plain "404 page not found"
to the rest. A deleted device still gets its 410, but only when the request
is signed with its key, kept in the new revoked_devices table. The migration
freeze answers only proven clients too.

Self-signed certs for the subscription (SUB_TLS=on) and the node hub got
fixed names (sen, awg-node); new ones get a random host-like name. Clients
and nodes pin the key, so existing certs keep working.

The panel could already issue a metrics-only token, but nothing in the UI
used it. The Monitor tab gets an "External Prometheus" card: issue, reissue
and revoke the token, with a ready prometheus.yml fragment whose address is
taken from the page, so it carries the secret path or the nginx subfolder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:52:55 +00:00
Claude 619af21085 Full uninstall from the installer
On a server with an existing installation the menu gets a third choice,
«Удалить»; UNINSTALL=y runs it without the menu. After typing «удалить» it
offers a backup of /etc/amnezia and cli.env to /root, then removes the
services and the systemd unit, brings awg1 down, drops the shaping and
torrent nft tables, closes the ports it had opened in UFW, firewalld or
iptables, removes the Prometheus that `awg-ctrl monitor install` set up
(only ours), and deletes /opt/awg-control, /etc/amnezia, the awg-ctrl
command, the sysctl file and the install logs, which hold the panel
password. AmneziaWG packages, module and PPA go only on a separate yes.

kill_related moved above the menu so the uninstall can use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:45:48 +00:00
Claude 13da9a7200 Secret URL path for the panel
The panel port is open to the internet, and its login page and JS bundle
gave it away to any scanner in one GET. The installer now generates a
random UI_PREFIX: from outside the panel answers only under
http://IP:PORT/<prefix>/ and gives Go's plain "404 page not found" to
everything else. Loopback needs no prefix (CLI, local Prometheus, nginx on
the same host); while a move code is out, POST /migrate/v1/import is let
through without it, since the code carries no path.

A reinstall with kept data keeps the prefix; UI_PREFIX=off or a custom value
can be set before running the installer. A moved panel brings its prefix in
UI_PREFIX_MOVED, so old bookmarks and API clients keep working through the
relay. awg-ctrl status and credentials show the full panel address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:41:09 +00:00
Claude aa848de31c Hide the brand from unauthenticated requests
GET /ui/brand answered without auth with {"brand":"Forgetting",…}: one
request was enough for a scanner to tell a found port is this panel. It now
requires the panel JWT (Go and the Node reference alike). The login screen
says just «Вход», the page title is neutral until login, and the wordmark
loads after sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:30:40 +00:00
Claude 63e84e0df7 Random I1 and MTU on install
I1 becomes a DNS query to a random popular domain (A/AAAA) instead of the
shared icloud.com response every key carried. The format sticks to tags both
the kernel module and amneziawg-go accept and is checked before awg1.conf is
written: a bad I1 fails the whole awg-quick up. With KEEP_DATA=y the old I1
is kept, or generated if missing — the server never checks it, old keys keep
their own.

MTU is picked from 1320–1376 (AWG_MTU overrides) and kept on reinstall.
awg-ctrl now reads MTU from awg1.conf for vpn:// keys instead of a constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:26:07 +00:00
86 changed files with 6789 additions and 1827 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.4.7
VERSION=0.5.3
+314 -75
View File
@@ -1,7 +1,30 @@
# Forgetting
Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module)
Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module)
> [!WARNING]
> Данный проект предназначен только для использования в законных целях.
- **VPN-пользователи** с ключами `vpn://` для AmneziaVPN и **мастер-ключи** `sen://` для SenAWG:
одна ссылка на несколько устройств, каждое регистрируется само.
- **Несколько серверов** под одной панелью: ноды подключаются к ней сами, панель можно
вынести на отдельный сервер без VPN.
- **Журнал подключений**: кто, когда и сколько был в VPN, кто давно не подключался.
- **Мониторинг**: встроенный Prometheus и графики в панели, метрики для своего Prometheus.
- **Защита от обнаружения**: параметры обфускации, порты и пути у каждого сервера свои, а
открытые порты отвечают сканеру как пустой веб-сервер.
## Содержание
- [Архитектура](#архитектура)
- [Требования](#требования)
- [Установка](#установка) · [Параметры установщика](#параметры-установщика) · [Обновление и удаление](#обновление-и-удаление)
- [Несколько серверов](#несколько-серверов)
- [Защита от обнаружения](#защита-от-обнаружения)
- [Журнал подключений](#журнал-подключений)
- [Мониторинг](#мониторинг)
- [Панель за nginx](#панель-за-nginx)
- [API](#api)
- [Управление](#управление) · [Настройки `cli.env`](#настройки-clienv)
## Архитектура
Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли;
@@ -9,17 +32,34 @@
| Компонент | Роль |
|---|---|
| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** (`awg-control ui`) | Веб-панель (фронтенд на React, `awg-ui/dist`), аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. |
| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу (`users.db`) и журнал подключений, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** (`awg-control ui`) | Веб-панель (React, `awg-ui/dist`), вход по JWT, прокси к awg-ctrl, публичный API `/api/v1`, подписка `sen://`, хаб нод и `/metrics`. База — `ui.db`. |
| **awg-agent** (`awg-control agent`) | Только на ноде: исходящее WSS-соединение к панели, исполняет её запросы на локальном awg-ctrl. |
| **cli** (`awg-ctrl …`) | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
| **cli** (`awg-ctrl …`) | Менеджер процессов, логин и пароль панели, привязка нод, перенос панели, мониторинг, TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только
у awg-ui, awg-ctrl проверяет подпись публичным. Данные — в `/etc/amnezia/amneziawg/` (вне
каталога установки, переустановку переживают), настройки сервисов — в
`/opt/awg-control/cli/cli.env`.
Порты наружу (номера у каждого сервера случайные):
| Порт | Что | Кто открывает |
|---|---|---|
| `SERVER_PORT`/udp | VPN-клиенты | везде, где есть VPN |
| `UI_PORT`/tcp | панель и API, под секретным путём | панель |
| `SUB_PORT`/tcp | подписка `sen://` (по умолчанию HTTPS) | панель |
| `NODE_PORT`/tcp | хаб нод (WSS) | панель, если принимает ноды |
Нода открывает только UDP для клиентов: к панели она подключается сама.
## Требования
- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и переопределяется через `AMNEZIA_PPA_SUITE`)
- **ОС**: Ubuntu 24.04+ (тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка
экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и
переопределяется через `AMNEZIA_PPA_SUITE`).
- **Права**: root.
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
- **Виртуализация**: KVM или железо (не OpenVZ/LXC/Docker/WSL — нужен загружаемый
kernel-модуль AmneziaWG).
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
- **Архитектура**: x86_64 или aarch64.
@@ -34,34 +74,186 @@ bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/instal
```
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор
с текстом скрипта.
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор с текстом скрипта.
```bash
sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)"
```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
на 2.0 можно так:
Установщик спросит роль сервера:
```bash
AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
```
| Роль | Что ставится |
|---|---|
| **Панель + VPN** (`standalone`) | Всё на одной машине; к ней можно подключать ноды |
| **Только панель** (`core`) | Панель без VPN; серверы подключаются к ней нодами |
| **Нода** (`node`) | VPN без панели; подключается к панели по строке `awgjoin://…` («Добавить сервер» в панели) |
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически.
В конце установщик печатает адрес панели **вместе с секретным путём** —
`http://IP:PORT/<путь>/` — логин и пароль. Без пути панель отвечает `404`, поэтому адрес
сохраните целиком; позже его покажут `awg-ctrl status` и `awg-ctrl credentials`.
Источник архива и имя панели не зашиты в код — задаются переменными
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
(подробности — в комментариях `install.sh` и в `.env`).
Порты на самом сервере установщик открывает сам (UFW, firewalld или iptables — что
активно). **Фаервол хостера** (панель провайдера, security group) отсюда не виден: открой там
порты из итога установки, иначе клиенты не подключатся, а на сервере всё будет выглядеть
исправным.
### Панель за nginx
Сервер ставится на AmneziaWG 3.1. Если модуль в ядре старше 3.x или ядро старше 5.5,
установка остановится с диагностикой — поставить на 2.0 можно через `AWG_GEN=auto`. Перевести
сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели: пользователи сохраняются,
ключи перевыпускаются автоматически.
Панель работает и в корне домена, и в любой подпапке — пути в ней относительные,
настраивать сам префикс не нужно. Пример для `https://example.com/forgetting/`
(`39708` — `PORT` панели из `/opt/awg-control/cli/cli.env`):
### Параметры установщика
Задаются переменными окружения перед запуском, например
`ROLE=node JOIN='awgjoin://…' bash <(curl -Ls …/install.sh)`. Всё необязательно.
| Переменная | Что делает |
|---|---|
| `ROLE` | `standalone`, `core` или `node` — без вопроса |
| `JOIN` | строка `awgjoin://…` для ноды |
| `AWG_GEN` | `3.1` (по умолчанию), `auto` — 3.1, если ядро позволяет, иначе 2.0; `2.0` — не трогать модуль |
| `AWG_PIN` | `y` (по умолчанию) — закрепить версию пакетов AmneziaWG (`apt-mark hold`) |
| `AWG_PORT` | UDP-порт VPN вместо случайного |
| `AWG_MTU` | MTU туннеля вместо случайного (1280–1500) |
| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели — без вопросов |
| `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути |
| `SERVER_NAME` | имя VPN-сервера — без вопроса |
| `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов |
| `ACTION` | `update`, `reinstall` или `uninstall` — действие без меню |
| `UNINSTALL` | `y` — удалить без меню и без подтверждения |
| `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) |
| `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) |
| `BRAND`, `CHANNEL` | имя продукта в баннерах и панели |
### Обновление и удаление
Повторный запуск установщика на сервере с установкой — или пункты **7–9** меню `awg-ctrl`, или
команды `awg-ctrl update | reinstall | uninstall` — предлагает:
1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без
секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес
внешнего API нужно обновить. Оставить без пути — `UI_PREFIX=off` перед запуском или пустое
`UI_PREFIX=` в `cli.env`.
2. **Переустановить** — с нуля; пользователей и ключ сервера можно сохранить. Тогда по умолчанию
остаются и порты (VPN, панели, подписки, хаба), параметры обфускации, MTU, секретный путь,
логин и пароль панели — они зашиты в выданные ключи, ссылки и закладки. Прежняя установка
убирается только после ответа «да» на «Продолжить?».
3. **Удалить** — полностью (без меню: `UNINSTALL=y`, подтверждение «удалить» тогда не
спрашивается).
Удаление снимает сервисы и юнит systemd, интерфейс `awg1`, nft-таблицы шейпинга и блокировки
торрентов, открытые установщиком порты, Prometheus из `awg-ctrl monitor install`,
`/opt/awg-control`, `/etc/amnezia` (пользователи, ключи, базы), команду `awg-ctrl`, sysctl-файл
и логи установки (в них пароль панели). Перед этим установщик предлагает сохранить копию данных
в `/root/forgetting-backup-<время>.tar.gz`. AmneziaWG (пакеты, модуль, PPA) — по отдельному
вопросу; общие зависимости (dkms, build-essential, iptables, заголовки ядра) остаются. Ноду
после удаления уберите и из списка серверов панели.
## Несколько серверов
**Подключить сервер.** В панели «Добавить сервер» выдаёт строку `awgjoin://…` (действует час).
На новом сервере — установщик с ролью «Нода» и этой строкой, или на уже установленном —
`awg-ctrl join '<awgjoin://…>'`. Агент ноды сам подключается к хабу панели по WSS; доверие —
закреплённый отпечаток сертификата хаба и ключ ноды. В панели нода появляется в списке
серверов, ею управляют так же, как своим. `awg-ctrl unjoin` — отвязать.
**Перенести панель на отдельный сервер** (например, без VPN). «Настройки» → «Перенос» или CLI:
1. На новом сервере — установка с ролью «Только панель».
2. На новой панели — код переноса `awgmove://…`; на старой — вставить его (или
`awg-ctrl migrate export` на старом и `awg-ctrl migrate import <архив>` на новом).
3. Старый сервер становится нодой #0 новой панели и пересылает туда свои прежние порты:
ссылки `sen://`, ноды и внешний API продолжают работать, закладки панели — тоже (новая
панель принимает и свой секретный путь, и старый).
4. Когда раздел «Перенос» покажет, что новый адрес знают все устройства и ноды, старый
сервер можно выключать. Ноды, переехавшие вручную, — `awg-ctrl rehome <хост[:порт]>`.
`awg-ctrl migrate cancel` — отменить на старом сервере, пока перенос не завершён.
## Защита от обнаружения
Раньше у всех установок были одинаковые порт, параметры обфускации и ответы на запросы — одно
правило DPI находило их все. Теперь общего у серверов нет:
**VPN.** На новой установке случайны:
- UDP-порт (20000–59999), если не задан `AWG_PORT`;
- параметры обфускации `Jc/Jmin/Jmax`, `S1–S4` (размеры handshake-пакетов разные — по длине
тип пакета не узнать), `H1–H4` (непересекающиеся диапазоны);
- `I1` — пакет-маскировка перед handshake: DNS-запрос к случайному популярному домену (формат
проверяется до записи — его принимают и kernel-модуль, и amneziawg-go);
- MTU в диапазоне 1320–1376.
При переустановке с сохранением данных всё это берётся из прежнего `awg1.conf`: параметры
зашиты в выданные ключи. Поэтому **серверы, поставленные до рандомизации, сохраняют старые общие
значения** — чтобы уйти от них, нужна переустановка без сохранения данных и раздача новых
ключей. Исключение — `I1`: его сервер не сверяет, и если в конфиге его не было, при переустановке
он генерируется новым, не ломая старые ключи.
**Открытые порты** отвечают тому, кто не доказал право на ответ, как пустой веб-сервер на Go —
`404 page not found`:
| Порт | Кому отвечает по протоколу |
|---|---|
| Панель | запросам под секретным путём (`UI_PREFIX`); с loopback — и без него |
| Подписка `sen://` | `peek`/`register` с секретом мастер-ключа из ссылки; остальным запросам — с подписью ключом устройства |
| Хаб нод | агенту, подписавшему своим ключом сам запрос на WebSocket |
Подробности:
- `GET /ui/brand` (имя и версия) — только после входа; экран логина не показывает имя продукта.
- Удалённому устройству подписка отвечает `410` (снять конфиг), только если запрос подписан его
ключом (`revoked_devices`); устройства, удалённые до этой версии, получают тихий `404`.
- Пока выдан код переноса, `POST /migrate/v1/import` принимается и без секретного пути.
- Агенты нод прежних версий подписи не шлют: хаб пускает их, пока в панели есть нода, ни разу
не подключавшаяся по-новому, и пишет об этом в лог. Обновили все ноды — путь без подписи
закрывается сам (`HUB_LEGACY` в `cli.env` — принудительно). Часы ноды могут расходиться с
панелью до суток.
- Самоподписанные сертификаты подписки и хаба получают случайное имя вместо общих `sen` /
`awg-node`. Клиенты и ноды сверяют ключ, а не имя; прежние сертификаты остаются со старым именем.
**Чего это не закрывает.** Секретный путь и тихий `404` защищают от активного сканирования.
Панель по умолчанию работает по голому HTTP — её трафик (пути, JSON, токены) виден в сети.
Ставьте её за nginx с TLS ([ниже](#панель-за-nginx)) или заходите по VPN. Подписка `sen://`
по умолчанию HTTPS, хаб — всегда TLS.
## Журнал подключений
Вкладка «Подключения» показывает по выбранному серверу:
- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько
скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP.
- **Кто когда был** — все пользователи и устройства `sen://` сервера: онлайн сейчас, когда
были на связи последний раз и кто не подключался ни разу с начала журнала.
Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий
подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» —
время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск
awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не
записывается. Серверы на прежней версии журнала не ведут — вкладка попросит их обновить.
## Мониторинг
**Встроенный Prometheus.** «Настройки» → «Prometheus» → «Установить»: панель просит awg-ctrl
поставить его (`awg-ctrl monitor install`, только Debian/Ubuntu). Он слушает только
`127.0.0.1:9090`, хранит 31 день и забирает метрики **всех серверов** панели. Графики — скорость,
устройства онлайн, нагрузка, CPU и память — появляются через минуту-две. Удалить — там же или
`awg-ctrl monitor remove`. Графики — во вкладке «Монитор».
У панели без своего VPN Prometheus ставится на ноду (выберите её в шапке панели). Он
забирает метрики у агента на `127.0.0.1`, а агент — у панели внутри TLS-соединения с хабом;
панель читает графики через того же агента. Снаружи этот трафик не отличить от обычной связи
ноды с панелью, секретный путь ему не нужен. Агенты старше 0.4.6 этого не умеют.
**Свой Prometheus.** «Настройки» → «Prometheus» → «Токен метрик»: выдать токен «только метрики»
(он не открывает ни панель, ни API) и скопировать готовый фрагмент `prometheus.yml`. Адрес в нём
берётся от страницы панели, поэтому уже содержит секретный путь
(`metrics_path: /<путь>/metrics`) или подпапку nginx. По голому HTTP токен и метрики идут
открытым текстом — скрейпьте через nginx с TLS или по VPN. Метрики по отдельным пирам
выключены (`METRICS_PEERS=on` в `cli.env` — включить).
## Панель за nginx
Панель работает и в корне домена, и в любой подпапке — пути в ней относительные. Пример для
`https://example.com/forgetting/` (`39708` — `UI_PORT` из `/opt/awg-control/cli/cli.env`):
```nginx
location = /forgetting { return 301 /forgetting/; }
@@ -74,32 +266,32 @@ location ^~ /forgetting/ {
}
```
Адрес клиента панель берёт из `X-Forwarded-For` / `X-Real-IP` только от доверенного
прокси: с этой же машины (loopback) — всегда, с других адресов — если они перечислены
в `TRUST_PROXY` в `cli.env` (IP или сети через запятую, например
`TRUST_PROXY=10.0.0.5,192.168.1.0/24`), после чего `awg-ctrl restart`. От остальных
заголовки игнорируются. Без этого все входы шли бы с адреса прокси и делили один
лимит неудачных попыток.
nginx на этой же машине ходит к панели по loopback, поэтому секретный путь ему не нужен —
подпапка в nginx и становится путём панели (выберите неочевидную). Адрес клиента панель берёт
из `X-Forwarded-For` / `X-Real-IP` только от доверенного прокси: с loopback — всегда, с других
адресов — если они перечислены в `TRUST_PROXY`. Без этого все входы шли бы с адреса прокси и
делили один лимит неудачных попыток.
Сам порт панели после этого можно закрыть снаружи (`ufw deny <PORT>/tcp`) — порты
подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты
и ноды ходят на них напрямую, не через nginx.
Сам порт панели после этого можно закрыть снаружи (`ufw deny <UI_PORT>/tcp`). Порты подписки
(`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты и ноды ходят на них
напрямую, не через nginx.
## API
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
стабильный контракт для сторонних программ, авторизация по API-ключу.
**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
URL в обоих случаях — адрес сервера и порт, выбранный при установке.
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — стабильный контракт
для сторонних программ, авторизация по API-ключу. **Роуты панели** — всё, что умеет
веб-интерфейс, авторизация по JWT.
В примерах `HOST:PORT` — адрес панели **вместе с секретным путём**:
`http://203.0.113.9:39708/k3f9xQ2mTq8vLw0a`. Без пути снаружи любой запрос получает `404`.
> [!WARNING]
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет
> без TLS и ограничения по адресам.
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный контракт,
> в отличие от `/api/v1`. По голому HTTP они идут открытым текстом: ставьте панель за TLS.
### Публичный API (`/api/v1`)
Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`.
Ключ создаётся в «Настройках», на вкладке «API-ключи», и уходит в заголовке `X-Api-Key`.
Открытое значение показывается один раз при создании — на сервере лежит только
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
`psk_key` и `pub_key` не покидают сервер.
@@ -117,22 +309,20 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
| `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден |
| `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` |
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
ключ = удалить и создать заново.
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или `"3.1"`). Операции
только CRD: у пира нет изменяемых полей, «перевыпустить» ключ = удалить и создать заново.
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка;
`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://`
заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается
заголовком `X-Server-Id: <id>`, сервер вне набора → `404`.
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; `0` — сервер
самой панели, остальные — подключённые ноды). Пользователи `vpn://` заводятся на сервере ключа
по умолчанию; другой сервер из набора выбирается заголовком `X-Server-Id: <id>`, сервер вне
набора → `404`.
#### Мастер-ключи `sen://`
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое
устройство само регистрируется по ссылке и получает пира на всех серверах ключа,
его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только
если **все** его серверы входят в набор API-ключа; остальные для него не
существуют (`404`).
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое устройство
само регистрируется по ссылке и получает пира на всех серверах ключа, его приватный ключ сервер
не видит. API-ключ видит и меняет мастер-ключ, только если **все** его серверы входят в набор
API-ключа; остальные для него не существуют (`404`).
```bash
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
@@ -160,12 +350,11 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
| `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен |
| `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает |
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем
`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`.
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`), поэтому переустановка их
не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей
внутренней авторизацией. Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys`
(с полем `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
перевыпуская ключ, и `DELETE /ui/apikeys/:id`.
### Авторизация панели
@@ -180,19 +369,20 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`; сервер — заголовком `X-Server-Id`
(без него — сервер по умолчанию). Ниже — роуты, которые дёргает сама панель; в отличие от
`/api/v1` их форма может меняться.
### Состояние
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.5.3" }` — имя и версия панели для вордмарка; за авторизацией, чтобы сканер не опознал панель |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс:
`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: `"2"` или
`"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
### Пользователи
@@ -212,8 +402,16 @@ curl -s -X POST http://HOST:PORT/api/users \
```
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
base64url-декодировать, отбросить первые 4 байта (длина), распаковать
`zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
base64url-декодировать, отбросить первые 4 байта (длина), распаковать `zlib inflate` → JSON;
текст конфига лежит в `last_config.config`.
### Журнал подключений
| Метод | Ответ |
|---|---|
| `GET /ui/connlog` | Журнал сервера из `X-Server-Id`: `{ events: [{ id, at, event, name, owner, ip, key?, device?, duration?, rx?, tx? }], peers: [{ name, owner, ip, online, last_seen, key?, device? }], since, keep_days }`. `event` — `connect` или `disconnect` (у него `duration`, `rx`, `tx` — со стороны сервера); `key`/`device` — мастер-ключ и устройство `sen://`. Параметры: `limit` (до 500), `before=<id>` — следующая страница, `q` — поиск по имени, владельцу, IP. `501` — сервер старой версии |
Без имён `sen://` тот же журнал отдаёт awg-ctrl: `GET /api/connlog`.
### Интерфейс AWG
@@ -223,10 +421,9 @@ base64url-декодировать, отбросить первые 4 байта
| `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся |
| `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` |
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не
3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 2.0.
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не 3.x или ядро
старше 5.5, и `500` с текстом причины, если ядро не приняло 3.1-конфиг — в этом случае прежний
конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0.
### Мастер-ключи
@@ -245,24 +442,66 @@ base64url-декодировать, отбросить первые 4 байта
| `DELETE /ui/devices/:id` | Отвязать устройство |
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
### Метрики
| Метод | Что делает |
|---|---|
| `GET /ui/metrics-token` | `{ exists, prefix?, created_at?, last_used?, per_peer }` — токен для своего Prometheus |
| `POST /ui/metrics-token` | Выпустить или перевыпустить: `201` с `token` (показывается один раз); старый перестаёт работать |
| `DELETE /ui/metrics-token` | Отозвать |
| `GET /metrics` | Экспозиция Prometheus по всем серверам; `Authorization: Bearer awgm_…` |
## Управление
```bash
awg-ctrl # меню
awg-ctrl start|stop|restart|status
awg-ctrl credentials [user|pass]
awg-ctrl start|stop|restart|status # status показывает и адрес панели с путём
awg-ctrl credentials [user|pass] # логин и пароль панели, адрес
awg-ctrl join '<awgjoin://…>' [--no-panel] / unjoin
awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес
awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN
awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели»)
awg-ctrl peers [drop <область>] # пиры sen:// на этом сервере по панелям
awg-ctrl monitor install|status|remove # встроенный Prometheus
awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов»)
awg-ctrl migrate import <архив>
awg-ctrl migrate cancel
awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh)
awg-ctrl panel [path new|off|<путь>] # адрес панели; новый, свой или без секретного пути
awg-ctrl log [<поиск>] [-n N] # журнал VPN-подключений этого сервера
awg-ctrl log seen # кто когда был: онлайн, последний раз, не подключался
awg-ctrl metrics [token|revoke] # токен и prometheus.yml для своего Prometheus
awg-ctrl hub [legacy on|off|auto] # ноды хаба: кто с подписью, кто ещё старый
awg-ctrl check # что на сервере выдаёт его сканеру и DPI
```
В меню `awg-ctrl` те же возможности — пункты 10–13: адрес и секретный путь панели, журнал,
«кто когда был» и проверка скрытности.
`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё
зеркало), `uninstall` берёт его копию из установки и работает без сети.
Через systemd (автозапуск на загрузке):
```bash
systemctl start|stop|restart awg-control
```
### Настройки `cli.env`
`/opt/awg-control/cli/cli.env` пишет установщик; после правки — `awg-ctrl restart`. Чаще всего
нужны:
| Переменная | Что делает |
|---|---|
| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели (`awg-ctrl credentials`) |
| `UI_PREFIX` | секретный путь панели; пусто — без пути |
| `UI_PREFIX_MOVED` | путь панели, переехавшей сюда: принимается вместе со своим |
| `SUB_PORT`, `SUB_TLS`, `SUB_HOST` | подписка `sen://`: порт, HTTPS, адрес в ссылках |
| `NODE_PORT` | хаб нод; пусто — ноды не принимаются |
| `HUB_LEGACY` | `on` / `off` — пускать ли агентов нод без подписи; пусто — пока не обновлены все |
| `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` |
| `METRICS_PEERS` | `on` — метрики по отдельным пирам |
| `AUDIT` | `off` — выключить периодический аудит пиров |
| `INSTALLER_URL` | откуда `awg-ctrl update`/`reinstall` берут установщик (своё зеркало) |
## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.4.7",
"version": "0.5.3",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.4.7",
"version": "0.5.3",
"description": "",
"main": "dist/index.js",
"scripts": {
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AWG Panel</title>
<title>Вход</title>
</head>
<body>
<div id="root"></div>
+129 -31
View File
@@ -15,7 +15,7 @@ const now = () => Math.floor(Date.now() / 1000);
const rnd = (n: number) => Math.floor(Math.random() * n);
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
const GEN = "3.1";
const MOCK_VERSION = "0.3.3";
const MOCK_VERSION = "0.5.3";
// ── пользователи (vpn://) ──
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
@@ -37,9 +37,10 @@ const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"])
const U_ = (q: express.Request) => usersOf[srv(q)];
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
const mnodes: MNode[] = [
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.3.2" }, // отстаёт от панели
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.5.0" },
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: false, pending: false, version: "0.4.5" }, // старше 0.5.0: хаб не подключает
{ id: 4, name: "Helsinki", ip: "192.0.2.44", gen: "3.1", online: true, pending: false, version: "0.5.4" }, // новее панели — жёлтый чип
];
let nodeId = 3;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
@@ -53,8 +54,8 @@ interface Dev { id: number; device_id: string; device_name: string; platform: st
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] },
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 2, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1, 2] }, // все места заняты; Tokyo (2) ещё не получил устройства
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0, 1] }, // «все серверы»
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
@@ -66,7 +67,9 @@ const devOut = (m: MK, d: Dev) => ({ ...d,
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
// Как на сервере: за авторизацией, без токена — 401 «Unauthorized» (на него панель выходит из сессии).
app.get("/ui/brand", (q, r) => { if (!q.headers.authorization) { r.status(401).json({ error: "Unauthorized" }); return; }
r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }); });
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (q, r) => {
@@ -75,9 +78,17 @@ app.get("/health", (q, r) => {
});
// ── серверы ──
// Как MinNodeVersion в хабе: ноду старше хаб не подключает (в моке она просто offline + outdated)
const MIN_NODE = "0.5.0";
const cmpVer = (a: string, b: string) => {
const pa = a.split("-")[0].split(".").map(Number), pb = b.split("-")[0].split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d; }
return 0;
};
const nodeView = () => [
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version,
outdated: !n.pending && (!n.version || cmpVer(n.version, MIN_NODE) < 0),
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
];
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
@@ -106,23 +117,46 @@ const MON_LOGS = {
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
};
let monJob: { action: "install" | "remove"; start: number } | null = null;
// Как internal/ui/monitor.go: Prometheus у панели один. target — чей awg-ctrl ставит и удаляет его:
// где он уже стоит; если нигде — свой (панель + VPN) или ?server= (панель без VPN, MOCK_ROLE=core).
// Ход операции у каждого сервера свой (его awg-ctrl). Ставить можно, только если awg-ctrl сервера
// на связи и новый: Tokyo без связи, у Amsterdam старый агент (/api/monitor/job → 404) — у них
// can_install: false и команда вручную; у Frankfurt осталась неудачная прошлая установка.
type MonJob = { action: "install" | "remove"; start: number; lines?: string[]; exit?: number };
const monJobs: Record<number, MonJob> = {
1: { action: "install", start: now() * 1000 - 86400e3, exit: 100, lines: ["1/4 установка пакета prometheus", "Reading package lists…",
"E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1873 (unattended-upgr)", "E: Unable to acquire the dpkg frontend lock, is another process using it?"] },
};
let monReady = false;
let monHost = 0; // где стоит (для панели без VPN — нода; у панели с VPN всегда 0, «этот сервер»)
const monHostOf = (id: number) => ({ id, name: nodeView().find(n => n.id === id)?.name ?? `#${id}` });
const MON_MS = 6000;
const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS;
app.get("/ui/monitor", (_q, r) => {
if (monJob && !monRunning()) monReady = monJob.action === "install";
let job = null;
if (monJob) {
const lines = MON_LOGS[monJob.action];
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length;
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") };
const monRunning = (j?: MonJob) => !!j && j.exit === undefined && Date.now() - j.start < MON_MS;
const monTarget = (q: express.Request) => monReady || !MOCK_CORE ? monHost : Number(q.query.server ?? 0);
const monCan = (id: number) => { const n = mnodes.find(x => x.id === id); return !n || (n.online && n.version !== null); };
app.get("/ui/monitor", (q, r) => {
const target = monTarget(q), can = monCan(target), mj = monJobs[target];
if (mj && mj.exit === undefined && !monRunning(mj)) { // операция дошла до конца
mj.exit = 0;
monReady = mj.action === "install";
if (monReady) monHost = target;
}
// MOCK_ROLE=core/retire — панель без VPN: Prometheus на ноде #0 (сервер, с которого она переехала)
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", host: MOCK_CORE ? { id: 0, name: "VPN" } : null, job });
let job = null;
if (can && mj) {
const lines = mj.lines ?? MON_LOGS[mj.action];
const n = monRunning(mj) ? Math.max(1, Math.floor(((Date.now() - mj.start) / MON_MS) * lines.length)) : lines.length;
job = { action: mj.action, running: monRunning(mj), exit: monRunning(mj) ? null : mj.exit ?? 0, log: lines.slice(0, n).join("\n") };
}
r.json({ ready: monReady, can_install: can, command: "awg-ctrl monitor install", host: MOCK_CORE ? monHostOf(target) : null, job });
});
for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); });
app.post(`/ui/monitor/${action}`, (q, r) => {
const target = monTarget(q), n = mnodes.find(x => x.id === target);
if (n && !n.online) return r.status(502).json({ error: "awg-ctrl недоступен" });
if (n && n.version === null) return r.status(501).json({ error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" });
if (!monRunning(monJobs[target])) monJobs[target] = { action, start: Date.now() };
r.status(202).json({ running: true, action });
});
}
// Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя;
// MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»);
@@ -183,19 +217,38 @@ app.get("/ui/monitor/overview", (q, r) => {
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
// Один сервер — тот, что выбран в сервер-баре (?server=<id>): ядро (0) или нода
const id = Number(q.query.server ?? 0);
const k = id === 0 ? 0.7 : 0.3;
const down1 = scale(down, k), up1 = scale(up, k);
const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты
if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } });
r.json({ range: q.query.range ?? "1h", step, from, to,
servers: [{ id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: down1.at(-1)![1], up: up1.at(-1)![1], online: id === 0 ? 7 : 3,
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30,
used_down: used(down1), used_up: used(up1) }],
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } });
// Ряды одного сервера: ядро (0) — основная нагрузка, нода Frankfurt (1) — поменьше
const one = (id: number) => {
const k = id === 0 ? 0.7 : 0.3;
const d = scale(down, k), u = scale(up, k);
return {
row: { id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: d.at(-1)![1], up: u.at(-1)![1], online: id === 0 ? 7 : 3,
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30,
used_down: used(d), used_up: used(u) },
series: { down: d, up: u, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) },
};
};
const empty = { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] };
// Без ?server= — все серверы (как monitor.go): ряды трафика, устройств и потерь суммируются, процессор
// и память — среднее. Метрики отдают ядро и Frankfurt; у Amsterdam (3) старый агент — его нет.
if (q.query.server === undefined) {
const all = [one(0), one(1)];
const zip = (key: keyof typeof empty, avg: boolean) => all[0].series[key].map(([t], n) => {
const sum = all.reduce((acc, x) => acc + x.series[key][n][1], 0);
return [t, avg ? sum / all.length : sum] as [number, number];
});
return r.json({ range: q.query.range ?? "1h", step, from, to, servers: all.map(x => x.row),
total: { down: zip("down", false), up: zip("up", false), online: zip("online", false), heavy: zip("heavy", false),
drops: zip("drops", false), cpu: zip("cpu", true), mem: zip("mem", true) } });
}
// Один сервер (?server=<id>)
const id = Number(q.query.server);
if (id !== 0 && id !== 1) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: empty });
const x = one(id);
r.json({ range: q.query.range ?? "1h", step, from, to, servers: [x.row], total: x.series });
});
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
@@ -211,7 +264,9 @@ app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView());
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = {};
// На сервере 0 деление канала включено сразу — чтобы был пример «фонового» приоритета: пользователь
// 10.9.0.2 и «MacBook Ивана» в мастер-ключе «Семья».
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = { 0: { enabled: true, downMbit: 100, upMbit: 50, auto: true } };
const shapingView = (id: number) => {
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
const on = settings.enabled;
@@ -254,6 +309,49 @@ app.put("/api/torrent", (q, r) => {
r.json(torrentView(id));
});
// Маскировка первого пакета (I1): у каждого сервера своя; Frankfurt (1) — старая нода без маршрута (404).
// «Сменить» берёт случайный шаблон из примеров — как GenI1 в awg-ctrl, только без случайных полей.
const I1_SAMPLES = [
{ kind: "dns", label: "DNS-запрос к ya.ru", i1: "<r 2><b 0x01000001000000000001027961027275000001000100002904d0000000000000>" },
{ kind: "quic", label: "QUIC Initial, 1250 байт", i1: "<b 0xc30000000108><r 8><b 0x00><b 0x0044e2><r 1232>" },
{ kind: "stun", label: "STUN binding request", i1: "<b 0x000100002112a442><r 12>" },
{ kind: "dns", label: "DNS-запрос к www.google.com", i1: "<r 2><b 0x010000010000000000000377777706676f6f676c6503636f6d0000410001>" },
{ kind: "quic", label: "QUIC Initial, 1200 байт", i1: "<b 0xc90000000108><r 8><b 0x08><r 8><b 0x0044a6><r 1190>" },
];
const maskingOf: Record<number, number> = { 0: 0, 2: 1, 3: 2 };
app.get("/api/masking", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(I1_SAMPLES[maskingOf[tsid(q)] ?? 3]));
app.post("/api/masking", (q, r) => {
const id = tsid(q);
if (id === 1) return r.status(404).json({ error: "Not found" });
const cur = maskingOf[id] ?? 3;
maskingOf[id] = (cur + 1 + Math.floor(Math.random() * (I1_SAMPLES.length - 1))) % I1_SAMPLES.length;
r.json(I1_SAMPLES[maskingOf[id]]);
});
// Журнал подключений (вкладка «Подключения»): несколько событий за двое суток.
app.get("/ui/connlog", (q, r) => {
if (q.query.before) return r.json({ events: [], peers: [], since: null, keep_days: 30 });
const now = Math.floor(Date.now() / 1000);
const who = [
{ pub_key: "aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9d=", name: "alice", owner: "", ip: "10.9.0.2" },
{ pub_key: "Zx8Cv6Bn4Mq2We0Rt8Yu6Io4Pa2Sd0Fg8Hj6Kl4Zx2C=", name: "", owner: "mk1", ip: "10.9.0.7", key: "Семья", device: "Pixel 7 Pro" },
{ pub_key: "Qw1Er3Ty5Ui7Op9As1Df3Gh5Jk7Lz9Xc1Vb3Nm5Qw7E=", name: "bob", owner: "", ip: "10.9.0.3" },
];
const events = [
{ ...who[0], id: 6, at: now - 300, event: "connect" },
{ ...who[1], id: 5, at: now - 3900, event: "disconnect", duration: 5400, rx: 812e6, tx: 64e6 },
{ ...who[2], id: 4, at: now - 9000, event: "disconnect", duration: 640, rx: 12e6, tx: 2e6 },
{ ...who[2], id: 3, at: now - 9640, event: "connect" },
{ ...who[1], id: 2, at: now - 9300, event: "connect" },
{ ...who[0], id: 1, at: now - 90000, event: "disconnect", duration: 7200, rx: 2.1e9, tx: 190e6 },
];
const peers = [
{ ...who[0], online: true, last_seen: now - 20 },
{ ...who[1], online: false, last_seen: now - 3900 },
{ ...who[2], online: false, last_seen: now - 9000 },
];
r.json({ events, peers, since: now - 3 * 86400, keep_days: 30 });
});
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
+2 -1
View File
@@ -67,9 +67,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
try {
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
const r = spawnSync("openssl", [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=" + crypto.randomBytes(6).toString("hex"),
], { stdio: "ignore" });
if (r.status !== 0) throw new Error("openssl");
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.4.7",
"version": "0.5.3",
"private": true,
"scripts": {
"dev": "vite",
+3 -3
View File
@@ -206,9 +206,9 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true });
});
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
// до выдачи JWT. Наружу уходит только имя продукта и версия.
app.get("/ui/brand", (_req: Request, res: Response) => {
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
// Экран логина обходится без вордмарка.
app.get("/ui/brand", requireAuth, (_req: Request, res: Response) => {
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
});
+511 -265
View File
File diff suppressed because it is too large Load Diff
+209 -77
View File
@@ -3,15 +3,17 @@
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import './tokens.css';
import './App.css';
import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
TOKEN_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId, serversLabel, openTab, MIN_NODE_VERSION,
type NodeInfo, type Brand,
} from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh, IcoChevronDown } from './components/icons';
import ServerBar from './components/ServerBar';
import DrumTitle from './components/DrumTitle';
import { useTheme, setThemeMode } from './lib/theme';
import AddServerModal from './components/AddServerModal';
import ServerSettingsModal from './components/ServerSettingsModal';
import { TABS, TABS_BY_GROUP } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
@@ -27,14 +29,17 @@ export default function App() {
const [panelVersion, setPanelVersion] = useState<string | null>(null);
const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [serversOpen, setServersOpen] = useState(false); // выпадающий список серверов
const serverSelectRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState(TABS[0].id);
// Встроенный Prometheus: null — ещё не знаем; fresh — только что поставили (пункт «Монитор» въезжает в меню);
// leaving — только что удалили (пункт ещё в меню, пока доигрывает уход)
const [prom, setProm] = useState<{ ready: boolean; fresh: boolean; leaving: boolean } | null>(null);
const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const { theme } = useTheme();
const touchStartX = useRef(0);
const touchCurrentX = useRef(0);
@@ -169,13 +174,25 @@ export default function App() {
// Восстановить сессию из сохранённого токена
useEffect(() => {
// Сбрасываем сессию только на 401: сетевой сбой или 502, пока панель перезапускается,
// раньше тоже выкидывали на экран входа. В остальных случаях пробуем снова через 5 с.
const saved = localStorage.getItem(TOKEN_KEY);
if (saved) {
startSession(saved).catch(() => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
if (!saved) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let alive = true;
const attempt = () => {
startSession(saved).catch(e => {
if (!alive) return;
if (axios.isAxiosError(e) && e.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY);
setToken('');
} else {
timer = setTimeout(attempt, 5000);
}
});
}
};
attempt();
return () => { alive = false; clearTimeout(timer); };
}, [startSession]);
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
@@ -185,23 +202,20 @@ export default function App() {
return () => clearInterval(t);
}, [token, loadNodes]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
// Имя продукта и версия — с сервера, только после логина: роут за авторизацией, чтобы
// панель не опознавалась сканером. Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
axios.get('/ui/brand')
if (!token) return;
axios.get('/ui/brand', { headers: { Authorization: `Bearer ${token}` } })
.then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK,
channel: data.channel || '',
version: data.version || '',
}))
.catch(() => { /* остаётся фолбэк */ });
}, []);
}, [token]);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
}, [theme]);
useEffect(() => { document.title = token ? brand.brand : 'Вход'; }, [token, brand.brand]);
// Блокировать прокрутку фона когда drawer открыт
useEffect(() => {
@@ -209,6 +223,72 @@ export default function App() {
return () => { document.body.style.overflow = ''; };
}, [drawerOpen]);
// На широком окне (≥1200px) меню постоянное — модальный drawer там не нужен
useEffect(() => {
const mq = window.matchMedia('(min-width: 1200px)');
const h = () => { if (mq.matches) setDrawerOpen(false); };
mq.addEventListener('change', h);
return () => mq.removeEventListener('change', h);
}, []);
// Список серверов закрывается кликом мимо него и по Escape
useEffect(() => {
if (!serversOpen) return;
const down = (e: MouseEvent) => {
if (!serverSelectRef.current?.contains(e.target as Node)) setServersOpen(false);
};
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setServersOpen(false); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
}, [serversOpen]);
// Есть ли встроенный Prometheus — от него зависит пункт «Монитор» (requires: 'prometheus').
// Узнаём при входе, раз в минуту (могли поставить из CLI) и сразу от вкладок (announcePrometheus).
// Анимации — только при переходах «не было → есть» и «было → нет», не при каждой загрузке страницы.
const notePrometheus = useCallback((ready: boolean) => {
setProm(p => (p?.ready === ready ? p : { ready, fresh: p !== null && ready, leaving: !!p?.ready && !ready }));
}, []);
// Уход длится --duration-medium2 (300 мс); потом пункт убираем из меню совсем
useEffect(() => {
if (!prom?.leaving) return;
const t = setTimeout(() => setProm(p => (p?.leaving ? { ...p, leaving: false } : p)), 300);
return () => clearTimeout(t);
}, [prom?.leaving]);
useEffect(() => {
if (!token) { setProm(null); return; }
const load = () => apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token)
.then(r => notePrometheus(!!r.ready))
.catch(() => { /* не знаем — меню не трогаем */ });
void load();
const t = setInterval(load, 60000);
const h = (e: Event) => notePrometheus((e as CustomEvent<boolean>).detail);
window.addEventListener('awg:prometheus', h);
return () => { clearInterval(t); window.removeEventListener('awg:prometheus', h); };
}, [token, notePrometheus]);
// tabVisible — вкладка доступна; в меню она ещё и на время ухода
const tabVisible = (t: { requires?: 'prometheus' }) => t.requires !== 'prometheus' || !!prom?.ready;
const tabLeaving = (t: { requires?: 'prometheus' }) => t.requires === 'prometheus' && !!prom?.leaving;
const navGroups = TABS_BY_GROUP
.map(g => ({ ...g, tabs: g.tabs.filter(t => tabVisible(t) || tabLeaving(t)) }))
.filter(g => g.tabs.length > 0);
// Активная вкладка пропала из меню (Prometheus удалили) — на первую
useEffect(() => {
const t = TABS.find(x => x.id === activeTab);
if (t && !tabVisible(t)) setActiveTab(TABS[0].id);
});
// Переход на другую вкладку из самой вкладки (openTab в lib/shared)
useEffect(() => {
const h = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
if (TABS.some(t => t.id === id)) setActiveTab(id);
};
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Закрывать drawer по Escape
useEffect(() => {
if (!drawerOpen) return;
@@ -223,33 +303,27 @@ export default function App() {
const chrome = tab.chrome ?? {};
const showServerBar = chrome.serverBar !== false;
const showRestart = chrome.restart !== false;
const current = nodes.find(n => n.id === serverId) ?? null;
// Позиция вкладки в меню (по группам) — направление прокрутки заголовка-барабана
const tabOrder = navGroups.flatMap(g => g.tabs).findIndex(t => t.id === tab.id);
const themeToggle = (
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setThemeMode(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
);
return (
<div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
{!token ? (
<div className="login-screen">
<span className="theme-toggle--fixed">{themeToggle}</span>
<div className="login-card">
<span className="logo-name">{brand.brand}</span>
<span className="logo-name">Вход</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
@@ -285,7 +359,8 @@ export default function App() {
>
<IcoMenu />
</button>
<span className="top-bar-title">{tab.label}</span>
<span className="top-bar-title"><DrumTitle text={tab.label} order={tabOrder} /></span>
<span className="top-bar-end">{themeToggle}</span>
</header>
{/* Затемнение под drawer */}
@@ -301,6 +376,15 @@ export default function App() {
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Кнопка меню rail (600–1199px): раскрывает его в модальный drawer */}
<button
className="rail-menu"
aria-label={drawerOpen ? 'Свернуть меню' : 'Развернуть меню'}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(o => !o)}
>
<IcoMenu />
</button>
<div className="sidebar-logo">
<span className="logo-name">{brand.brand}</span>
<span className="sidebar-version">
@@ -308,48 +392,105 @@ export default function App() {
</span>
</div>
<hr className="drawer-divider" />
{TABS_BY_GROUP.map(g => (
{navGroups.map(g => (
<nav key={g.id} className="nav-group" aria-label={g.label}>
<span className="nav-group-title">{g.label}</span>
{g.tabs.map(t => (
<div
<button
key={t.id}
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
type="button"
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}${tabLeaving(t) ? ' nav-item--leave' : t.requires === 'prometheus' && prom?.fresh ? ' nav-item--enter' : ''}`}
aria-current={t.id === activeTab ? 'page' : undefined}
aria-hidden={tabLeaving(t) || undefined}
tabIndex={tabLeaving(t) ? -1 : undefined}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
<span className="nav-icon"><t.Icon /></span>
<span className="nav-label">{t.label}</span>
</button>
))}
</nav>
))}
<div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}>
<IcoLogout /> Выйти
<button className="btn btn--danger sidebar-logout" aria-label="Выйти" onClick={logout}>
<IcoLogout /> <span className="nav-label">Выйти</span>
</button>
</aside>
<main className="main">
<h2 className="page-title">{tab.label}</h2>
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && (
<ServerBar
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
onSettings={n => { pickServer(n.id); setSettingsFor(n); }}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
)}
{/* Шапка страницы: заголовок слева; справа — выбор сервера, перезапуск AWG и тема.
На телефоне заголовок и тема — в верхнем баре, выбор сервера — во всю ширину. */}
<div className="page-head">
<h2 className="page-title"><DrumTitle text={tab.label} order={tabOrder} /></h2>
<div className="page-actions">
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают.
Текущий сервер — кнопкой, остальные — выпадающим списком под ней. */}
{showServerBar && (
<div className="server-select" ref={serverSelectRef}>
<button
type="button"
className={`server-current${serversOpen ? ' server-current--open' : ''}`}
aria-expanded={serversOpen}
aria-controls="server-menu"
onClick={() => setServersOpen(o => !o)}
>
<span
className={`status-dot${current?.online ? ' status-dot--online' : ''}`}
role="img"
aria-label={current?.online ? 'на связи' : 'нет связи'}
/>
<span className="server-current-info">
<span className="server-card-name">
{current ? (current.health?.server || current.name) : 'Сервер не выбран'}
</span>
<span className="server-card-ip">
{!current ? 'добавьте ноду'
: current.pending ? 'ждёт подключения'
: current.outdated ? `нужно обновить до ${MIN_NODE_VERSION}`
: !current.online ? 'нет связи'
: current.health?.ip}
</span>
</span>
<span className="server-current-count">{serversLabel(nodes.length)}</span>
<span className="server-current-caret"><IcoChevronDown /></span>
</button>
{serversOpen && (
<div className="server-menu" id="server-menu" aria-label="Серверы">
<ServerBar
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={id => { pickServer(id); setServersOpen(false); }}
onAdd={() => { setServersOpen(false); setModal({}); }}
onJoin={n => { setServersOpen(false); setModal({ node: n }); }}
onDelete={deleteNode}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
onSettings={n => { setServersOpen(false); pickServer(n.id); openTab('settings', 'server'); }}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
</div>
)}
</div>
)}
{showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
<span className="page-theme">{themeToggle}</span>
</div>
</div>
{serverId === null && showServerBar ? (
<p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
@@ -373,15 +514,6 @@ export default function App() {
/>
)}
{settingsFor && (
<ServerSettingsModal
token={token}
node={settingsFor}
onClose={() => setSettingsFor(null)}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
+1 -1
View File
@@ -85,6 +85,7 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
<button className="btn btn--text" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
{result ? (
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
@@ -94,7 +95,6 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
Создать
</button>
)}
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
</div>
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState } from 'react';
// Заголовок-«барабан»: при смене текста старый уходит гранью барабана, новый выкатывается на его
// место. order — позиция вкладки в меню: дальше по списку — барабан крутится вверх, обратно — вниз.
// Первый показ — без анимации. Стили — App.css, «Заголовок-барабан».
export default function DrumTitle({ text, order }: { text: string; order: number }) {
const [s, setS] = useState({ text, order, from: null as string | null, up: true, n: 0 });
// Текст сменился — запоминаем прежний прямо в рендере, чтобы новый не мелькнул без анимации
if (s.text !== text) setS({ text, order, from: s.text, up: order >= s.order, n: s.n + 1 });
return (
<span key={s.n} className={`drum${s.from !== null ? ` drum--roll drum--${s.up ? 'up' : 'down'}` : ''}`}>
{s.from !== null && <span className="drum-out" aria-hidden="true">{s.from}</span>}
<span className="drum-in">{text}</span>
</span>
);
}
+20 -17
View File
@@ -1,27 +1,31 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
import { type NodeInfo, genLabel, cmpVersion, MIN_NODE_VERSION } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
// Версия Forgetting на сервере, три состояния: совпадает с панелью — нейтральный чип; нода отстаёт
// от панели (но не старше MIN_NODE_VERSION) или новее неё — жёлтый, стоит обновить; старше
// MIN_NODE_VERSION или не сообщает версию (агент старше 0.3.4) — красный: хаб такую ноду не подключает.
function versionChip(n: NodeInfo, panel: string | null) {
if (n.pending) return null;
let tip = '', warn = false;
let tip = '', level: '' | 'warn' | 'bad' = '';
if (!n.version) {
warn = true;
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
level = 'bad';
tip = `Версия неизвестна: нода старше 0.3.4, панель с ней не работает — обнови её до ${MIN_NODE_VERSION} (install.sh → «Обновить»)`;
} else if (!n.local && cmpVersion(n.version, MIN_NODE_VERSION) < 0) {
level = 'bad';
tip = `Слишком старая версия: панель с ней не работает, нужна не ниже ${MIN_NODE_VERSION} — обнови ноду (install.sh → «Обновить»)`;
} else if (panel && !n.local) {
const d = cmpVersion(n.version, panel);
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
if (d < 0) { level = 'warn'; tip = `Устарела: панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
else if (d > 0) { level = 'warn'; tip = `Нода новее панели (${panel}) — обнови панель`; }
}
const chip = (
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}>
<span className={`chip server-ver-chip${level ? ` server-ver-chip--${level}` : ''}`}>
{n.version ? `v${n.version}` : 'v?.?.?'}
</span>
);
@@ -60,18 +64,17 @@ export default function ServerBar({
onClick={() => onSelect(n.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
>
{/* toggle — виден на мобильном вместо чипа */}
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<span
className={`status-dot${n.online ? ' status-dot--online' : ''}`}
role="img"
aria-label={n.online ? 'на связи' : 'нет связи'}
/>
<div className="server-card-info">
<span className="server-card-name">{h?.server || n.name}</span>
{n.pending ? (
<span className="server-card-ip">ждёт подключения</span>
) : n.outdated ? (
<span className="server-card-ip server-card-ip--bad">нужно обновить до {MIN_NODE_VERSION}</span>
) : !n.online ? (
<span className="server-card-ip">нет связи</span>
) : h && (
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef } from 'react';
import { type NodeInfo, genLabel, serversLabel, MIN_NODE_VERSION } from '../lib/shared';
import { IcoChevronDown } from './icons';
// Выбор одного сервера внутри страницы: чьи графики показывать в «Мониторе».
// Выглядит как выбор сервера в шапке страницы: кнопка с текущим сервером (точка связи, имя, IP,
// «N серверов», стрелка) и под ней меню-список; выбранная строка залита primary-container. Выбор
// локальный: сервер в шапке панели он не меняет. Серверы без связи видны, но недоступны (на них
// ничего не поставить) — кроме pickOffline: история на графиках у них есть. all — первой строкой
// «Все серверы» (ALL_SERVERS).
// Меню раскрывается от правого края кнопки, как в шапке.
export const ALL_SERVERS = -1;
export default function ServerSelect({ nodes, value, onChange, name, all, pickOffline }: {
nodes: NodeInfo[];
value: number;
onChange: (id: number) => void;
name: string; // id меню (aria-controls)
all?: boolean;
pickOffline?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Закрывается кликом мимо и по Escape — как меню серверов в шапке
useEffect(() => {
if (!open) return;
const down = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
}, [open]);
const online = nodes.filter(n => n.online).length;
const current = nodes.find(n => n.id === value) ?? null;
const isAll = value === ALL_SERVERS;
const pick = (id: number) => { setOpen(false); if (id !== value) onChange(id); };
const sub = (n: NodeInfo) => n.pending ? 'ждёт подключения' : n.outdated ? `нужно обновить до ${MIN_NODE_VERSION}` : !n.online ? 'нет связи' : n.health?.ip ?? '';
return (
<div className="server-select" ref={ref}>
<button type="button" className={`server-current${open ? ' server-current--open' : ''}`}
aria-expanded={open} aria-controls={name} onClick={() => setOpen(o => !o)}>
<span className={`status-dot${(isAll ? online > 0 : current?.online) ? ' status-dot--online' : ''}`}
role="img" aria-label={(isAll ? online > 0 : current?.online) ? 'на связи' : 'нет связи'} />
<span className="server-current-info">
<span className="server-card-name">
{isAll ? 'Все серверы' : current ? (current.health?.server || current.name) : 'Сервер не выбран'}
</span>
<span className="server-card-ip">{isAll ? `на связи ${online} из ${nodes.length}` : current ? sub(current) : ''}</span>
</span>
<span className="server-current-count">{serversLabel(nodes.length)}</span>
<span className="server-current-caret"><IcoChevronDown /></span>
</button>
{open && (
<div className="server-menu" id={name} role="listbox" aria-label="Серверы">
{all && (
<button type="button" role="option" aria-selected={isAll}
className={`server-option${isAll ? ' server-option--active' : ''}`} onClick={() => pick(ALL_SERVERS)}>
<span className="status-dot server-option-nodot" aria-hidden="true" />{/* место точки — текст в один столбец */}
<span className="server-current-info">
<span className="server-card-name">Все серверы</span>
<span className="server-option-sub">{serversLabel(nodes.length)} · на связи {online}</span>
</span>
</button>
)}
{nodes.map(n => {
const off = !n.online && !pickOffline;
return (
<button key={n.id} type="button" role="option" aria-selected={n.id === value} disabled={off}
className={`server-option${n.id === value ? ' server-option--active' : ''}`} onClick={() => pick(n.id)}>
<span className={`status-dot${n.online ? ' status-dot--online' : ''}`} role="img"
aria-label={n.online ? 'на связи' : 'нет связи'} />
<span className="server-current-info">
<span className="server-card-name">{n.health?.server || n.name}</span>
<span className="server-card-ip">{sub(n)}</span>
</span>
{n.health?.gen && <span className="server-option-sub">{genLabel(n.health.gen)}</span>}
</button>
);
})}
</div>
)}
</div>
);
}
+32
View File
@@ -11,6 +11,11 @@ export const IcoMenu = () => (
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
export const IcoChevronDown = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
export const IcoUsers = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
@@ -108,10 +113,37 @@ export const IcoMove = () => (
</svg>
);
export const IcoGear = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
export const IcoCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5 10 17.5 19 7"/>
</svg>
);
// «Ещё» (MD3 more_vert): меню действий строки
export const IcoMore = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/>
</svg>
);
export const IcoHistory = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = {
settings: IcoGear,
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
history: IcoHistory,
};
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import QRCode, { type QRCodeErrorCorrectionLevel } from 'qrcode';
// Фирменный QR: скруглённые модули (соседи сливаются в «капли», одиночный — круг) и угловые
// «глаза» с фиолетовой серединкой. Рисуется в SVG по матрице библиотеки, отдаётся data-URL для <img>.
//
// Цвета — светлой темы и не зависят от текущей: сканеры читают только тёмный код на светлом фоне,
// а токены тёмной темы светлые. Логотипа нет: у vpn:// (уровень 'L') нет запаса коррекции ошибок.
const DARK = '#3a2e22'; // on-surface светлой темы
const ACCENT = '#6a45b8'; // primary светлой темы
const LIGHT = '#ffffff';
const MARGIN = 2; // тихая зона в модулях — как было у toDataURL
const R = 0.5; // радиус открытого угла модуля (0.5 — одиночный модуль становится кругом)
// Квадрат 1×1 в (x, y) со скруглением только тех углов, у которых оба соседа по сторонам пусты.
function cell(x: number, y: number, tl: boolean, tr: boolean, br: boolean, bl: boolean): string {
const a = tl ? R : 0, b = tr ? R : 0, c = br ? R : 0, d = bl ? R : 0;
return `M${x + a} ${y}H${x + 1 - b}` + (b ? `a${b} ${b} 0 0 1 ${b} ${b}` : '')
+ `V${y + 1 - c}` + (c ? `a${c} ${c} 0 0 1 ${-c} ${c}` : '')
+ `H${x + d}` + (d ? `a${d} ${d} 0 0 1 ${-d} ${-d}` : '')
+ `V${y + a}` + (a ? `a${a} ${a} 0 0 1 ${a} ${-a}` : '') + 'Z';
}
// Скруглённый прямоугольник как подпуть (для «глаз»).
function rrect(x: number, y: number, w: number, h: number, r: number): string {
return `M${x + r} ${y}H${x + w - r}a${r} ${r} 0 0 1 ${r} ${r}V${y + h - r}a${r} ${r} 0 0 1 ${-r} ${r}`
+ `H${x + r}a${r} ${r} 0 0 1 ${-r} ${-r}V${y + r}a${r} ${r} 0 0 1 ${r} ${-r}Z`;
}
// Бросает, если текст не помещается в QR на этом уровне коррекции.
export function qrDataUrl(text: string, errorCorrectionLevel: QRCodeErrorCorrectionLevel): string {
const { modules } = QRCode.create(text, { errorCorrectionLevel });
const n = modules.size;
const finder = (x: number, y: number) =>
(x < 7 && y < 7) || (x >= n - 7 && y < 7) || (x < 7 && y >= n - 7);
const on = (x: number, y: number) =>
x >= 0 && y >= 0 && x < n && y < n && !finder(x, y) && !!modules.get(y, x);
// Все модули — одним путём: соседние квадраты сливаются без щелей сглаживания.
let body = '';
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (!on(x, y)) continue;
const up = on(x, y - 1), down = on(x, y + 1), left = on(x - 1, y), right = on(x + 1, y);
body += cell(x, y, !up && !left, !up && !right, !down && !right, !down && !left);
}
}
// «Глаза»: кольцо 7×7 (внешний минус внутренний контур, evenodd) и серединка 3×3.
let rings = '', pupils = '';
for (const [x, y] of [[0, 0], [n - 7, 0], [0, n - 7]]) {
rings += rrect(x, y, 7, 7, 2) + rrect(x + 1, y + 1, 5, 5, 1.5);
pupils += rrect(x + 2, y + 2, 3, 3, 1);
}
const s = n + MARGIN * 2;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-MARGIN} ${-MARGIN} ${s} ${s}" width="560" height="560">`
+ `<rect x="${-MARGIN}" y="${-MARGIN}" width="${s}" height="${s}" fill="${LIGHT}"/>`
+ `<path d="${body}" fill="${DARK}"/>`
+ `<path d="${rings}" fill="${DARK}" fill-rule="evenodd"/>`
+ `<path d="${pupils}" fill="${ACCENT}"/>`
+ '</svg>';
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
+26
View File
@@ -56,6 +56,19 @@ export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export const SERVER_KEY = 'awg_server';
export const SETTINGS_SECTION_KEY = 'awg_settings_section'; // открытый раздел «Настроек»
// Встроенный Prometheus установлен или удалён: вкладка «Монитор» в меню есть только при нём.
// Кто узнал состояние (раздел установки, сам «Монитор») — сообщает App, тот прячет или показывает пункт.
export const announcePrometheus = (ready: boolean) =>
window.dispatchEvent(new CustomEvent('awg:prometheus', { detail: ready }));
// Открыть другую вкладку панели (App слушает событие). section — раздел «Настроек»: его вкладка
// читает из SETTINGS_SECTION_KEY при монтировании, поэтому пишем до перехода.
export function openTab(id: string, section?: string) {
if (section) try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.dispatchEvent(new CustomEvent('awg:open-tab', { detail: id }));
}
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
export interface NodeInfo {
@@ -66,6 +79,7 @@ export interface NodeInfo {
pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null;
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
outdated?: boolean; // старше MIN_NODE_VERSION: панель её не подключает, пока не обновят
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
}
@@ -90,6 +104,18 @@ export interface TorrentStatus {
error?: string;
}
// «1 сервер», «3 сервера», «5 серверов»
export function serversLabel(n: number): string {
const m10 = n % 10, m100 = n % 100;
const word = m10 === 1 && m100 !== 11 ? 'сервер'
: m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'сервера' : 'серверов';
return `${n} ${word}`;
}
// Минимальная версия Forgetting на ноде (в хабе — MinNodeVersion, internal/ui/nodes.go): старше — хаб
// ноду не подключает, чип версии красный; между ней и версией панели — жёлтый (просто стоит обновить).
export const MIN_NODE_VERSION = '0.5.0';
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
export function cmpVersion(a: string, b: string): number {
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Тема панели: светлая, тёмная или как в системе. Одно хранилище на всё приложение —
// переключатель в шапке и вкладка «Настройки» читают и меняют его через useTheme().
import { useSyncExternalStore } from 'react';
import { THEME_KEY } from './shared';
export type ThemeMode = 'light' | 'dark' | 'system';
export type Theme = 'light' | 'dark';
const dark = window.matchMedia('(prefers-color-scheme: dark)');
const listeners = new Set<() => void>();
function readMode(): ThemeMode {
try {
const v = localStorage.getItem(THEME_KEY);
if (v === 'dark' || v === 'system') return v;
} catch { /* не критично */ }
return 'light'; // без сохранённого выбора — светлая, как было до режима «как в системе»
}
let mode = readMode();
const resolved = (): Theme => (mode === 'system' ? (dark.matches ? 'dark' : 'light') : mode);
function apply() {
document.documentElement.setAttribute('data-theme', resolved());
listeners.forEach(l => l());
}
// «Как в системе» следует за сменой темы ОС на лету
dark.addEventListener('change', () => { if (mode === 'system') apply(); });
apply();
export function setThemeMode(next: ThemeMode) {
mode = next;
try { localStorage.setItem(THEME_KEY, next); } catch { /* не критично */ }
apply();
}
const subscribe = (l: () => void) => { listeners.add(l); return () => { listeners.delete(l); }; };
// mode — выбор пользователя, theme — что реально применено.
export function useTheme(): { mode: ThemeMode; theme: Theme } {
const snap = useSyncExternalStore(subscribe, () => `${mode}:${resolved()}`);
const [m, t] = snap.split(':') as [ThemeMode, Theme];
return { mode: m, theme: t };
}
-7
View File
@@ -1,7 +0,0 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"group": "main",
"order": 2
}
-11
View File
@@ -1,11 +0,0 @@
{
"id": "audit",
"label": "Аудит",
"icon": "shield",
"group": "server",
"order": 1.8,
"chrome": {
"serverBar": false,
"restart": false
}
}
+36
View File
@@ -0,0 +1,36 @@
/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы:
на телефоне колонки сами переносятся. */
.cl { display: flex; flex-direction: column; gap: 12px; }
.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.cl-search { flex: 1; min-width: 200px; width: auto; }
.cl-hint { margin: 0; font-size: 13px; color: var(--neutral-text); }
.cl-err { text-align: left; }
.cl-list {
list-style: none; margin: 0; padding: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--shape-large);
overflow: hidden;
}
.cl-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px;
padding: 12px 16px;
border-top: 1px solid var(--outline-variant);
}
.cl-row:first-child { border-top: none; }
.cl-time { flex: 0 0 110px; font-size: 13px; color: var(--neutral-text); font-variant-numeric: tabular-nums; }
.cl-chip { flex: none; min-width: 112px; }
.cl-who { flex: 1 1 200px; display: flex; flex-direction: column; min-width: 0; }
.cl-who b { font-weight: 500; overflow-wrap: anywhere; }
.cl-sub { font-size: 12px; color: var(--neutral-text); overflow-wrap: anywhere; }
.cl-session, .cl-seen { font-size: 13px; color: var(--on-surface); font-variant-numeric: tabular-nums; }
.cl-empty { padding: 24px 16px; text-align: center; color: var(--neutral-text); }
.cl-more { display: flex; justify-content: center; padding: 12px; border-top: 1px solid var(--outline-variant); }
@media (max-width: 640px) {
.cl-time { flex-basis: auto; }
.cl-views { width: 100%; }
.cl-views .segmented-item { flex: 1; }
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useMemo } from 'react';
import { apiFetch, bytes, timeAgo, type PageProps } from '../../lib/shared';
import { IcoCheck, IcoRefresh } from '../../components/icons';
import './connlog.css';
// Вкладка «Подключения»: журнал VPN выбранного сервера. Ведёт его awg-ctrl (internal/ctrl/connlog.go)
// по handshake и счётчикам WireGuard раз в 30 с, панель подставляет имена ключей и устройств sen://
// (/ui/connlog). Время отключения — последняя активность, точность около 30 с.
interface Who { pub_key: string; name: string; owner: string; ip: string; key?: string | null; device?: string | null; foreign?: boolean }
interface Ev extends Who { id: number; at: number; event: 'connect' | 'disconnect'; duration?: number | null; rx?: number | null; tx?: number | null }
interface Peer extends Who { online: boolean; last_seen: number | null }
interface Log { events: Ev[]; peers: Peer[]; since: number | null; keep_days: number }
const errText = (e: any): string => e?.response?.data?.error ?? 'Не удалось получить журнал';
// Кто это: пользователь — по имени, устройство sen:// — ключ и устройство.
function who(w: Who): { title: string; sub: string } {
if (w.name) return { title: w.name, sub: 'пользователь' };
if (w.foreign) return { title: 'Устройство другой панели', sub: w.owner };
if (w.owner) {
return { title: w.device || 'Удалённое устройство', sub: w.key ? `мастер-ключ «${w.key}»` : 'мастер-ключ удалён' };
}
return { title: `ключ ${w.pub_key.slice(0, 10)}…`, sub: 'не из панели' };
}
const pad = (n: number) => String(n).padStart(2, '0');
function when(ts: number): string {
const d = new Date(ts * 1000), now = new Date();
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.toDateString() === now.toDateString()) return `сегодня ${time}`;
const y = new Date(now); y.setDate(now.getDate() - 1);
if (d.toDateString() === y.toDateString()) return `вчера ${time}`;
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}${d.getFullYear() === now.getFullYear() ? '' : '.' + d.getFullYear()} ${time}`;
}
function duration(s: number): string {
if (s < 60) return `${s} сек`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} мин`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ч ${m % 60} мин`;
return `${Math.floor(h / 24)} дн ${h % 24} ч`;
}
export default function ConnLogPage({ token, showMsg }: PageProps) {
const [view, setView] = useState<'events' | 'peers'>('events');
const [log, setLog] = useState<Log | null>(null);
const [more, setMore] = useState<Ev[]>([]); // подгруженные страницы старее первой
const [done, setDone] = useState(false); // старее событий нет
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState('');
// Первая страница: обновляем раз в 30 с — журнал пишется с тем же шагом.
const load = useCallback(async () => {
try {
const l = await apiFetch('GET', '/ui/connlog?limit=200', token) as Log;
setLog(l); setErr('');
if (l.events.length < 200) setDone(true);
} catch (e) { setErr(errText(e)); }
}, [token]);
useEffect(() => {
void load();
const t = setInterval(() => { void load(); }, 30000);
return () => clearInterval(t);
}, [load]);
const all = useMemo(() => {
const first = log?.events ?? [];
const seen = new Set(first.map(e => e.id));
return [...first, ...more.filter(e => !seen.has(e.id))];
}, [log, more]);
const loadMore = async () => {
const last = all[all.length - 1];
if (!last) return;
setBusy(true);
try {
const l = await apiFetch('GET', `/ui/connlog?limit=200&before=${last.id}`, token) as Log;
setMore(m => [...m, ...l.events]);
if (l.events.length < 200) setDone(true);
} catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
};
const q = query.trim().toLowerCase();
const match = (w: Who) => !q || [w.name, w.owner, w.ip, w.key ?? '', w.device ?? '', w.pub_key]
.some(s => s.toLowerCase().includes(q));
const events = all.filter(match);
const peers = (log?.peers ?? []).filter(match).sort((a, b) =>
Number(b.online) - Number(a.online) || (b.last_seen ?? 0) - (a.last_seen ?? 0));
const onlineNow = (log?.peers ?? []).filter(p => p.online).length;
return (
<div className="cl">
<div className="cl-toolbar">
<div className="segmented cl-views" role="radiogroup" aria-label="Вид">
<button type="button" role="radio" aria-checked={view === 'events'} className="segmented-item"
onClick={() => setView('events')}>{view === 'events' && <IcoCheck />}События</button>
<button type="button" role="radio" aria-checked={view === 'peers'} className="segmented-item"
onClick={() => setView('peers')}>{view === 'peers' && <IcoCheck />}Кто когда был</button>
</div>
<input className="field cl-search" type="search" placeholder="Имя, ключ, устройство или IP"
aria-label="Поиск" value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn btn--tonal" onClick={() => void load()} aria-label="Обновить"><IcoRefresh /> Обновить</button>
</div>
{err && <p className="login-error cl-err">{err}</p>}
{!log && !err && <p className="cl-hint">Загрузка…</p>}
{log && (
<p className="cl-hint">
Сейчас онлайн: <b>{onlineNow}</b> из {log.peers.length}.
{' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней.</> : 'Журнал пуст: события появятся при первом подключении.'}
{' '}Время отключения — последняя активность, точность около 30 секунд.
</p>
)}
{log && view === 'events' && (
<ul className="cl-list">
{events.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'Событий пока нет'}</li>}
{events.map(e => {
const w = who(e);
const on = e.event === 'connect';
return (
<li className="cl-row" key={e.id}>
<span className="cl-time">{when(e.at)}</span>
<span className={`chip chip--${on ? 'online' : 'offline'} cl-chip`}>
<span className="chip-dot" />{on ? 'подключился' : 'отключился'}
</span>
<span className="cl-who">
<b>{w.title}</b>
<span className="cl-sub">{w.sub}{e.ip && <> · {e.ip}</>}</span>
</span>
{!on && (
<span className="cl-session">
{e.duration != null && <>сессия {duration(e.duration)}</>}
{/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */}
{(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)}</> : null}
</span>
)}
</li>
);
})}
{!done && !q && all.length > 0 && (
<li className="cl-more">
<button className="btn btn--tonal" onClick={() => void loadMore()} disabled={busy}>
{busy ? 'Загрузка…' : 'Показать раньше'}
</button>
</li>
)}
</ul>
)}
{log && view === 'peers' && (
<ul className="cl-list">
{peers.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'На сервере нет пользователей'}</li>}
{peers.map(p => {
const w = who(p);
return (
<li className="cl-row" key={p.pub_key}>
<span className="cl-who">
<b>{w.title}</b>
<span className="cl-sub">{w.sub}{p.ip && <> · {p.ip}</>}</span>
</span>
{p.online ? (
<span className="chip chip--online cl-chip"><span className="chip-dot" />онлайн</span>
) : p.last_seen ? (
<span className="cl-seen">был {when(p.last_seen)} <span className="cl-sub">({timeAgo(p.last_seen)} назад)</span></span>
) : (
<span className="chip chip--offline cl-chip"><span className="chip-dot" />не подключался{log.since ? ` с ${when(log.since)}` : ''}</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "connlog",
"label": "Подключения",
"icon": "history",
"group": "main",
"order": 1.5
}
+6 -1
View File
@@ -4,7 +4,7 @@
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок)
// metadata.json — { id, label, icon, group?, order?, requires?, chrome? } (манифест: описание, группа меню, порядок)
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
@@ -35,6 +35,7 @@ export interface TabMeta {
icon: string; // имя иконки → ICONS
group: TabGroupId; // группа меню; без поля — 'main'
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
requires?: 'prometheus'; // вкладка в меню только при условии: 'prometheus' — встроенный Prometheus установлен
chrome?: TabChrome;
}
@@ -66,6 +67,9 @@ function toMeta(name: string, raw: unknown): TabMeta {
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
}
if (m.requires !== undefined && m.requires !== 'prometheus') {
throw new Error(`tabs/${name}/metadata.json: "requires" может быть только "prometheus"`);
}
const group = (m.group ?? 'main') as string;
if (!TAB_GROUPS.some(g => g.id === group)) {
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
@@ -76,6 +80,7 @@ function toMeta(name: string, raw: unknown): TabMeta {
icon: str('icon'),
group: group as TabGroupId,
order: m.order as number | undefined,
requires: m.requires as 'prometheus' | undefined,
chrome: m.chrome as TabChrome | undefined,
};
}
@@ -109,12 +109,12 @@ export default function BulkServersModal({ token, nodes, keys, defaults, onClose
{preview?.hidden.map(h => (
<p className="mk-notice" key={h.id}>
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
Расширьте его область на вкладке «API-ключи», если он ими управляет.
Расширьте его область в «Настройках», на вкладке «API-ключи», если он ими управляет.
</p>
))}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
<button className="btn btn--outline" onClick={onClose}>Отмена</button>
<button className="btn btn--text" onClick={onClose}>Отмена</button>
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
{busy ? 'Применяем…' : 'Применить'}
</button>
+280 -110
View File
@@ -2,9 +2,9 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
import { apiFetch, copyText, bytes, timeAgo, getServerId, cmpVersion, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { qrDataUrl } from '../../lib/qr';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoMore } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
@@ -23,18 +23,12 @@ interface Device {
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
}
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
// панель догонит, когда нода появится.
const syncLabel = (d: Device): string =>
d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : '';
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
const platformLabel = (d: Device): string =>
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
// Приоритет показываем, только когда он не обычный: «обычный» в каждой строке — шум
const bgOnly = (p?: Priority) => p === 'background' ? p : undefined;
const statusChip = (d: Device) => (
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
@@ -55,11 +49,18 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [enabled, setEnabled] = useState(true);
const [tls, setTls] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
// Маскировка первого пакета (I1) сервера из шапки: false — сервер её не умеет (старый awg-ctrl) или недоступен
const [masking, setMasking] = useState<'ready' | 'busy' | false>(false);
const [label, setLabel] = useState('');
const [limit, setLimit] = useState('3');
const [devs, setDevs] = useState<Record<number, Device[]>>({});
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
// Меню «⋮» ключа или устройства. position: fixed по кнопке: .table-card прокручивается вбок
// и обрезал бы меню у нижних строк. full — в меню ключа и «Скопировать ссылку» (в таблице;
// в карточке на телефоне ссылка — отдельной кнопкой).
const [menu, setMenu] = useState<{ kind: 'key' | 'device'; id: number; full: boolean; top: number; right: number; up: boolean } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
@@ -121,6 +122,21 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [label, limit, nodes, defaults, token, act]);
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/masking', token).then(() => { if (alive) setMasking('ready'); }).catch(() => {});
return () => { alive = false; };
}, [token]);
// Новый случайный I1 на сервере из шапки. Интерфейс не трогается: I1 нужен только клиентам,
// sen:// подхватят его со следующим обновлением профиля (≤ 60 с), выданные vpn:// остаются со старым.
const regenMasking = async () => {
setMasking('busy');
try { await apiFetch('POST', '/api/masking', token); showMsg('Маскировка сменена'); }
catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось сменить маскировку'); }
finally { setMasking('ready'); }
};
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
const remove = useCallback(async (k: MasterKey) => {
@@ -141,8 +157,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const showQR = useCallback(async (k: MasterKey) => {
try {
const { link, tls: t } = await fetchLink(k);
const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' });
setModal({ label: k.label, link, tls: t, dataUrl });
setModal({ label: k.label, link, tls: t, dataUrl: qrDataUrl(link, 'H') });
} catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
@@ -195,12 +210,48 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</span>
);
useEffect(() => {
if (!menu) return;
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
const close = () => setMenu(null);
const down = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) close(); };
const key = (e: KeyboardEvent) => {
if (e.key === 'Escape') { close(); document.querySelector<HTMLButtonElement>(`[data-menu-for="${menu.kind}-${menu.id}"]`)?.focus(); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const items = [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])];
const i = items.indexOf(document.activeElement as HTMLButtonElement);
items[(i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length]?.focus();
};
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
document.removeEventListener('mousedown', down);
document.removeEventListener('keydown', key);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [menu]);
const openMenu = (kind: 'key' | 'device', id: number, el: HTMLElement, full = false) => {
if (menu?.kind === kind && menu.id === id) return setMenu(null);
const r = el.getBoundingClientRect();
const up = r.bottom + 160 > window.innerHeight; // у нижнего края — вверх от кнопки
setMenu({ kind, id, full, top: up ? r.top - 4 : r.bottom + 4, right: window.innerWidth - r.right, up });
};
const moreButton = (kind: 'key' | 'device', id: number, label: string, full = false) => (
<button className="btn-icon mk-more" aria-label={label} aria-haspopup="menu" aria-expanded={menu?.kind === kind && menu.id === id}
data-menu-for={`${kind}-${id}`} onClick={e => openMenu(kind, id, e.currentTarget, full)}><IcoMore /></button>
);
// В таблице — QR и «⋮» (остальное в меню), чтобы колонка действий всегда помещалась.
// Подсказки — .tip-wrap, не title: нативная появляется с задержкой и бесполезна на тач-экране.
const actions = (k: MasterKey) => (
<div className="actions">
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button>
<button className="btn-icon" title="Настройки мастер-ключа" aria-label="Настройки мастер-ключа" aria-expanded={tools === k.id} onClick={() => openTools(k)}><IcoSettings /></button>
<button className="btn-icon btn-icon--danger" title="Удалить мастер-ключ" aria-label="Удалить мастер-ключ" onClick={() => remove(k)}><IcoTrash /></button>
<span className="tip-wrap tip-wrap--end" data-tip="QR-код"><button className="btn-icon" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button></span>
{moreButton('key', k.id, `Ещё: ${k.label}`, true)}
</div>
);
@@ -275,7 +326,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
const meter = (k: MasterKey, n: number) => (
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`}
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : ''}`}
role="progressbar" aria-label="Устройства" aria-valuemin={0}
aria-valuemax={k.device_limit} aria-valuenow={n}
title={`Устройств: ${n} из ${k.device_limit}`}>
@@ -288,22 +339,35 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
);
const newPsk = async (d: Device) => {
try {
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
await load();
} catch (e) { showMsg(errText(e)); }
};
const revoke = (d: Device) => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
};
const deviceActions = (d: Device) => (
<div className="actions">
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
try {
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
await load();
} catch (e) { showMsg(errText(e)); }
}}><IcoRefresh /></button>
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
}}><IcoTrash /></button>
<span className="tip-wrap tip-wrap--end" data-tip="Новый PSK"><button className="btn-icon" aria-label="Новый PSK" onClick={() => newPsk(d)}><IcoRefresh /></button></span>
<span className="tip-wrap tip-wrap--end" data-tip="Отозвать устройство"><button className="btn-icon btn-icon--danger" aria-label="Отозвать устройство" onClick={() => revoke(d)}><IcoTrash /></button></span>
</div>
);
// Короткий ID устройства: полностью — в подсказке, по клику копируется
const deviceId = (d: Device) => (
<span className="tip-wrap tip-wrap--start" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
</span>
);
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// по клику копируется.
const nameCell = (k: MasterKey) => (
@@ -325,72 +389,146 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (list: Device[]) => {
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
// Серверы ключа, куда ещё не добавлено устройство: был не в сети, когда оно подключалось или меняло
// ключ; панель добавит его сама, когда сервер появится (reconcile). Без ips (старая панель) — неизвестно.
const missingOn = (k: MasterKey, d: Device) => d.ips ? k.servers.filter(s => !d.ips!.some(x => x.server_id === s)) : [];
// «из 3 серверов», «из 21 сервера»
const ofServers = (n: number) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'сервера' : 'серверов'}`;
const syncFlag = (k: MasterKey, d: Device) => {
const miss = missingOn(k, d);
if (miss.length) return `${k.servers.length - miss.length} из ${ofServers(k.servers.length)}`;
return d.servers_ok < d.servers_total ? `${d.servers_ok} из ${ofServers(d.servers_total)}` : '';
};
const syncTip = (k: MasterKey, d: Device) => {
const miss = missingOn(k, d);
return miss.length ? `Нет на ${miss.map(s => serverName(nodes, s)).join(', ')} — добавится само, когда сервер будет на связи`
: 'Не на всех серверах ключа — добавится само, когда они будут на связи';
};
// Серверы ключа — в его строке под колонками IP и «Платформа»: «все серверы» (у ключа с одним
// сервером — его имя) светло-зелёным или «2 из 3 серверов» тёплым, если на каком-то ещё нет всех
// устройств ключа.
// Имена — в подсказке.
const keyServers = (k: MasterKey, list: Device[]) => {
const behind = (s: number) => list.some(d => missingOn(k, d).includes(s));
const late = k.servers.filter(behind);
const ready = k.servers.filter(s => !behind(s));
const names = (ids: number[]) => ids.map(s => serverName(nodes, s)).join(', ');
const text = late.length ? `${ready.length} из ${ofServers(k.servers.length)}`
: k.servers.length === 1 ? serverName(nodes, k.servers[0]) : 'все серверы';
const tip = late.length ? `Есть: ${names(ready) || 'нигде'}. Нет: ${names(late)} — добавятся сами, когда сервер будет на связи`
: k.servers.length > 1 ? names(k.servers) : null;
const chip = <span className={`chip ${late.length ? 'chip--warm' : 'chip--online'} mk-srv`}>{text}</span>; // всё на месте — светло-зелёный
return tip ? <span className="tip-wrap tip-wrap--end" data-tip={tip}>{chip}</span> : chip;
};
// Статус ключа по его устройствам: онлайн / когда были / «нет устройств», плюс фоновый приоритет и торренты
const keyStatus = (list: Device[], empty: boolean) => {
if (!list.length) return empty ? <span className="mk-flag">нет устройств</span> : null;
const on = list.filter(d => d.online).length;
const last = Math.max(...list.map(d => d.lastHandshake || 0));
const torrent = list.some(d => d.torrent);
const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority;
const prio = bgOnly(list.some(d => d.priority === 'background') ? 'background' : undefined);
return (
<>
<td>
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
</span>
</td>
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td>
<td colSpan={2}></td>
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
</span>
<PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} />
</>
);
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (k: MasterKey, list: Device[], empty: boolean) => {
if (!list.length) return <><td>{keyStatus(list, empty)}</td><td colSpan={2}>{keyServers(k, list)}</td><td></td><td></td></>;
return (
<>
<td><span className="user-chips">{keyStatus(list, empty)}</span></td>
<td colSpan={2}>{keyServers(k, list)}</td>
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
</>
);
};
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
// Один адрес — выбранного в шапке сервера, остальные — «+N» и подсказка со всеми. Адрес с другого
// сервера подписан его именем строкой ниже, иначе его примут за адрес выбранного.
const ipCell = (d: Device) => {
const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d);
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>;
const body = (
<span className="name-stack">
<span className="mk-ip-line">
<code className="mk-ip">{ip}</code>
{all.length > 1 && <span className="mk-ip-more">+{all.length - 1}</span>}
</span>
{other && serverId !== undefined && <span className="mk-sub">{serverName(nodes, serverId)}</span>}
</span>
);
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
: text;
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{body}</span>
: body;
};
const caret = (k: MasterKey) => (
// Самая новая версия SenAWG среди устройств панели: кто отстал — тёплой плашкой, как ноды в меню серверов
const newest = useMemo(() => Object.values(devs).flat().map(d => d.version).filter(Boolean)
.reduce<string | null>((a, v) => !a || cmpVersion(v, a) > 0 ? v : a, null), [devs]);
const versionTag = (d: Device, tip: 'start' | 'end') => {
if (!d.version) return null;
const old = !!newest && cmpVersion(d.version, newest) < 0;
return old
? <span className={`tip-wrap tip-wrap--${tip}`} data-tip={`Есть новее: ${newest} — обнови SenAWG на устройстве`}><span className="chip chip--warm mk-ver">{d.version}</span></span>
: <span className="mk-sub">{d.version}</span>;
};
const platformCell = (d: Device) => (
<span className="name-stack">
{d.platform || '—'}
{versionTag(d, 'end')}
</span>
);
// Раскрывать нечего — вместо стрелки пустое место той же ширины, имена остаются в одну линию
const caret = (k: MasterKey, n: number) => n === 0 ? <span className="mk-caret-gap" aria-hidden="true" /> : (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
onClick={() => fold(k.id)}>▾</button>
);
const deviceNode = (k: MasterKey, d: Device) => (
<li className="mk-node mk-node--device" key={d.id}>
<div className="mk-device">
<div className="mk-device-main">
<span className="mk-device-title">
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
// Телефон: устройство — строка списка в карточке ключа, а не карточка в карточке. Имя, статус и «⋮»;
// ниже мелко — платформа, версия, IP; трафик, приоритет и то, на скольких серверах оно есть.
const deviceRow = (k: MasterKey, d: Device) => {
const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d);
return (
<li className="mk-dev" key={d.id}>
<div className="mk-dev-head">
<span className="name-stack mk-dev-title">
<span className="mk-dev-name">{d.device_name || 'Устройство'}</span>
{deviceId(d)}
</span>
<span className="mk-device-sub">{platformLabel(d)}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
{statusChip(d)}
{moreButton('device', d.id, `Ещё: ${d.device_name || 'устройство'}`)}
</div>
{statusChip(d)}
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
<div className="mk-device-ip">
<IcoGlobe />
{ipCell(d)}
<PriorityChip priority={d.priority} torrent={d.torrent} />
<div className="mk-dev-meta">
<span>{d.platform || '—'}</span>
{versionTag(d, 'start')}
<span aria-hidden="true">·</span>
<code className="mk-ip">{ip}</code>
{all.length > 1 && <span className="mk-ip-more" aria-label={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(', ')}>+{all.length - 1}</span>}
{other && serverId !== undefined && <span>{serverName(nodes, serverId)}</span>}
</div>
<div className="mk-dev-meta">
<span className="mk-dev-traffic">↓ {bytes(d.rx)} ↑ {bytes(d.tx)}</span>
<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
{syncFlag(k, d) && <span>{syncFlag(k, d)}</span>}
{d.rekey_requested && <span>ждёт смены ключа</span>}
</div>
<div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
</div>
{deviceActions(d)}
</div>
</li>
);
</li>
);
};
return (
<>
@@ -401,6 +539,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
)}
{masking && (
<div className="mk-warn" role="note">
<span className="mk-warn-text">
<b>Маскировка первого пакета · {serverName(nodes, getServerId() ?? 0)}</b>
Перед подключением клиент шлёт пакет, похожий на обычный трафик. Новый шаблон устройства sen://
получат в течение минуты.
</span>
<button className="btn btn--tonal" onClick={regenMasking} disabled={masking === 'busy'}>
<IcoRefresh /> Сменить
</button>
</div>
)}
<div className="toolbar">
<input className="field" placeholder="Метка (напр. Семья)" value={label}
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
@@ -408,6 +559,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
<button className="btn btn--tonal" onClick={load}><IcoRefresh /> Обновить</button>
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
</div>
@@ -418,65 +570,56 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<table className="mk-table">
<thead>
<tr>
<th>Имя</th><th>Статус</th><th>Приоритет</th><th>IP</th><th>Платформа</th>
<th>Имя</th><th>Статус</th><th>IP</th><th>Платформа</th>
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
<tr><td colSpan={8} className="empty">Нет мастер-ключей</td></tr>
) : keys.map(k => {
if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
<td colSpan={8}>{deletingChip(k)}</td>
<td colSpan={7}>{deletingChip(k)}</td>
</tr>
);
const list = devs[k.id] ?? [];
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const open = !folded.has(k.id);
return (
<Fragment key={k.id}>
<tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(list)}
<td><span className="mk-name">{caret(k, count)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(k, list, count === 0)}
<td>{meter(k, count)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={8}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 ? ' mk-last' : ''}`}>
<td>
<span className="mk-indent">
<span className="name-stack">
{d.device_name || 'Устройство'}
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
{/* пометки — в строке ID, не рядом с именем: так колонка «Имя» уже и таблица влезает */}
<span className="mk-id-line">
{deviceId(d)}
{syncFlag(k, d) && <span className="tip-wrap tip-wrap--start mk-flag" data-tip={syncTip(k, d)}>· {syncFlag(k, d)}</span>}
{d.rekey_requested && <span className="mk-flag">· ждёт смены ключа</span>}
</span>
</span>
</span>
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
</td>
<td>{statusChip(d)}</td>
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td className="td-mono">{ipCell(d)}</td>
<td>{platformLabel(d)}</td>
<td><span className="user-chips">{statusChip(d)}<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td>{ipCell(d)}</td>
<td>{platformCell(d)}</td>
<td>{bytes(d.rx)}</td>
<td>{bytes(d.tx)}</td>
<td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
))}
{open && free > 0 && (
<tr className="mk-tr-free mk-last">
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
</tr>
)}
</Fragment>
);
})}
@@ -484,7 +627,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</table>
</div>
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */}
{/* Телефон и планшет: таблица скрыта (.table-card) — карточка на ключ: шапка, сводка, действия,
ниже списком его устройства */}
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
<ul className="mk-tree">
{keys.map(k => {
@@ -492,39 +636,65 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<li className="mk-root" key={k.id}>
<div className="mk-master">
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{deletingChip(k)}
<span className="mk-master-label">{nameCell(k)}</span>
</div>
<div className="mk-master-sum">{deletingChip(k)}</div>
</li>
);
const list = devs[k.id] ?? [];
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
{caret(k, count)}
<IcoKey />
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
<span className="mk-master-label">{nameCell(k)}</span>
{meter(k, count)}
{actions(k)}
</div>
<div className="mk-master-sum">
{keyStatus(list, count === 0)}
{keyServers(k, list)}
{dataFlag(k)}
</div>
<div className="mk-master-actions">
<button className="btn btn--tonal" onClick={() => showQR(k)}><IcoQR /> QR</button>
<button className="btn btn--outline" onClick={() => copyLink(k)}><IcoCopy /> Ссылка</button>
{moreButton('key', k.id, `Ещё: ${k.label}`)}
</div>
{tools === k.id && toolbox(k)}
{!isFolded && (
<ul className="mk-children">
{list.map(d => deviceNode(k, d))}
{free > 0 && (
<li className="mk-node mk-node--free"><div className="mk-free">Свободно: {free}</div></li>
)}
</ul>
)}
{!isFolded && list.length > 0 && <ul className="mk-devs">{list.map(d => deviceRow(k, d))}</ul>}
</li>
);
})}
</ul>
)}
{menu && (() => {
const run = (f: () => void) => () => { setMenu(null); f(); };
const at = { top: menu.top, right: menu.right };
const cls = `mk-menu${menu.up ? ' mk-menu--up' : ''}`;
if (menu.kind === 'device') {
const d = Object.values(devs).flat().find(x => x.id === menu.id);
if (!d) return null;
return (
<div className={cls} role="menu" aria-label={`Устройство ${d.device_name}`} ref={menuRef} style={at}>
<button role="menuitem" className="mk-menu-item" onClick={run(() => newPsk(d))}><IcoRefresh /> Новый PSK</button>
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => revoke(d))}><IcoTrash /> Отозвать</button>
</div>
);
}
const k = keys.find(x => x.id === menu.id);
if (!k) return null;
return (
<div className={cls} role="menu" aria-label={`Мастер-ключ ${k.label}`} ref={menuRef} style={at}>
{menu.full && <button role="menuitem" className="mk-menu-item" onClick={run(() => copyLink(k))}><IcoCopy /> Скопировать ссылку</button>}
<button role="menuitem" className="mk-menu-item" onClick={run(() => openTools(k))}><IcoSettings /> Настройки ключа</button>
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => remove(k))}><IcoTrash /> Удалить</button>
</div>
);
})()}
{bulk && (
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
@@ -538,8 +708,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setModal(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
</div>
</div>
</div>
+128 -150
View File
@@ -7,70 +7,84 @@ LICENSE file in the root directory of this source tree.
Общая дизайн-система — в App.css. */
.mk-notice {
font-size: 13px;
color: var(--error-text);
background: var(--error-bg);
border-radius: 16px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-error-container);
background: var(--error-container);
border-radius: var(--shape-large);
padding: 10px 16px;
}
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
/* Предупреждение о маскировке: тёплый tertiary, как чип «устарела» у версии сервера */
.mk-warn {
display: flex; align-items: center; gap: 12px;
background: var(--tertiary-container); color: var(--on-tertiary-container);
border-radius: var(--shape-large);
padding: 10px 12px 10px 16px;
}
.mk-warn-text {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
}
.mk-warn-text b { font-weight: var(--weight-medium); }
.mk-warn .btn { flex-shrink: 0; }
.mk-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
.mk-limit-input { width: 88px !important; }
/* ── таблица (десктоп) ── */
.mk-table td, .mk-table th { white-space: nowrap; }
.mk-table .td-actions { width: 150px; }
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
.mk-table .td-actions { width: 80px; } /* QR и «⋮» */
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master td { background: var(--surface-container); font-weight: var(--weight-medium); padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
.mk-master-uuid {
display: block;
font-family: var(--mono); font-size: 12px; font-weight: 400;
color: var(--neutral-text);
overflow: hidden; text-overflow: ellipsis;
}
.mk-name .mk-caret { margin-left: -8px; }
.mk-tr-master .mk-name { color: var(--on-surface); }
.mk-tr-master .mk-name svg { color: var(--primary); }
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
.mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; }
.mk-tr-device td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mk-id-line { display: inline-flex; align-items: baseline; gap: 6px; }
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
.mk-ip { font-family: var(--mono); }
.mk-ip { font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
/* вторая строка ячейки: сервер чужого адреса, версия клиента */
.mk-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mk-ip-line { display: inline-flex; align-items: center; gap: 6px; }
.mk-ip-more {
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface-variant);
background: var(--surface-container-high); border-radius: var(--shape-full); padding: 0 6px;
}
.mk-srv, .mk-ver { font-weight: var(--weight-regular); }
.mk-ver { align-self: flex-start; padding-top: 0; padding-bottom: 0; font-family: var(--mono); }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
.mk-tr-device td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
}
.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; }
.mk-tr-device.mk-last td:first-child { background-size: 2px 50%; }
.mk-indent { position: relative; display: inline-block; padding-left: 36px; }
.mk-indent::before {
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
border-top: 2px solid var(--outline);
}
.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; }
.mk-tr-free:hover { background: transparent; }
/* ── дерево (мобильный) ── */
.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; }
.mk-tree, .mk-devs { list-style: none; margin: 0; padding: 0; }
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
.mk-root {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 12px 18px 14px;
border-radius: var(--shape-extra-large);
padding: 12px 16px;
}
/* родитель */
/* шапка ключа: стрелка, ключ, метка с коротким UUID, полоска устройств */
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
.mk-master-label {
font-size: 16px;
font-weight: 500;
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
flex: 1;
min-width: 0;
@@ -83,13 +97,38 @@ LICENSE file in the root directory of this source tree.
border: 0; border-radius: 50%;
background: transparent;
color: var(--neutral-text);
font-size: 16px;
font-size: var(--body-large-size); line-height: var(--body-large-line);
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
transition: transform var(--duration-short3) var(--ease-standard);
}
.mk-caret:hover { background: var(--state-neutral); }
.mk-caret--folded { transform: rotate(-90deg); }
.mk-caret-gap { width: 28px; flex-shrink: 0; } /* ключ без устройств: место стрелки */
.mk-name .mk-caret-gap { margin-left: -8px; }
/* Меню «⋮» ключа (MD3 menu): плавает над таблицей, у кнопки справа */
.mk-menu {
position: fixed; z-index: 30;
min-width: 200px; padding: 6px;
background: var(--surface-container);
border-radius: var(--shape-large);
box-shadow: var(--elevation-2);
animation: mk-menu-in var(--duration-short4) var(--ease-emphasized-decelerate);
transform-origin: top right;
}
.mk-menu--up { transform: translateY(-100%); transform-origin: bottom right; }
@keyframes mk-menu-in { from { opacity: 0; scale: .92; } }
.mk-menu-item {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 40px; padding: 0 12px;
border: 0; border-radius: var(--shape-medium); background: transparent;
font-family: var(--font); font-size: var(--label-large-size); line-height: var(--label-large-line); font-weight: var(--weight-medium);
color: var(--on-surface); text-align: left; cursor: pointer;
}
.mk-menu-item svg { color: var(--on-surface-variant); flex-shrink: 0; }
.mk-menu-item--danger, .mk-menu-item--danger svg { color: var(--error); }
@media (pointer: coarse) { .mk-menu-item { min-height: 48px; } }
@media (prefers-reduced-motion: reduce) { .mk-menu { animation: none; } }
.mk-tools {
display: grid;
@@ -103,10 +142,10 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
border-radius: var(--shape-large);
padding: 12px 16px 14px;
}
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.mk-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.mk-box .mk-box-title { margin-bottom: 2px; }
/* строка настройки: слева название и пояснение, справа контрол */
.mk-setting {
@@ -117,25 +156,26 @@ LICENSE file in the root directory of this source tree.
.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
.mk-setting:last-child { padding-bottom: 0; }
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
.mk-setting-name { font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mk-setting-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mk-uuid {
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: var(--label-large-size); line-height: var(--label-large-line); }
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
/* прогресс занятых слотов с числом внутри: обычная заливка primary, цветом — только когда мест
не осталось (тёплый: это повод присмотреться, а не ошибка).
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
.mk-meter {
--mk-w: 136px;
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
--meter-on: #2a2118; /* цвет числа поверх заливки */
--meter-fill: var(--primary);
--meter-on: var(--on-primary); /* цвет числа поверх заливки */
position: relative;
width: var(--mk-w); height: 20px;
border-radius: var(--r-pill);
border-radius: var(--shape-full);
background: var(--outline-variant);
overflow: hidden;
flex-shrink: 0;
@@ -144,7 +184,7 @@ LICENSE file in the root directory of this source tree.
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--meter-fill);
overflow: hidden;
transition: width 0.25s, background 0.25s;
transition: width var(--duration-medium1) var(--ease-standard), background var(--duration-medium1) var(--ease-standard);
}
/* числа по краям внутри полоски: текущее слева, лимит справа */
.mk-meter-text {
@@ -153,90 +193,62 @@ LICENSE file in the root directory of this source tree.
box-sizing: border-box;
padding: 0 11px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 500;
font-size: var(--label-medium-size); line-height: var(--label-medium-line); font-weight: var(--weight-medium);
font-variant-numeric: tabular-nums;
color: var(--on-surface);
}
.mk-meter-text--on { color: var(--meter-on); }
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
.mk-meter--full { --meter-fill: var(--warn-dot); --meter-on: #2a2118; } /* все места заняты */
.mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); }
.mk-node { position: relative; padding: 4px 0; }
.mk-node::before {
content: '';
position: absolute;
left: -28px; top: 50%;
width: 28px;
border-top: 2px solid var(--outline);
}
/* последний узел: линия родителя обрывается на его «ножке» */
.mk-node:last-child::after {
content: '';
position: absolute;
left: -30px; top: calc(50% + 1px); bottom: 0;
width: 2px;
background: var(--surface-bright);
}
/* сводка (статус, серверы) и действия ключа */
.mk-master-sum { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
.mk-master-sum:empty { display: none; }
.mk-master-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.mk-master-actions .btn { flex: 1; }
.mk-tree .mk-more { width: 40px; height: 40px; }
/* на телефоне .tip-wrap по умолчанию во всю ширину — здесь чипы и ID идут в строку */
.mk-tree .tip-wrap { width: auto; }
.mk-device {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--surface-container);
border-radius: 14px;
padding: 8px 14px;
/* устройства ключа — строками списка под ним, разделены линией */
.mk-devs { margin: 12px -16px -12px; border-top: 1px solid var(--outline-variant); }
.mk-dev { padding: 10px 16px; }
.mk-dev + .mk-dev { border-top: 1px solid var(--outline-variant); }
.mk-dev-head { display: flex; align-items: center; gap: 8px; }
.mk-dev-title { flex: 1; min-width: 0; }
.mk-dev-name {
font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; }
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.mk-device-id {
font-family: var(--mono);
font-size: 11px;
color: var(--neutral-text);
background: var(--surface-bright);
border-radius: var(--r-pill);
padding: 1px 8px;
min-width: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.mk-dev-head .mk-more { margin: -4px -8px -4px 0; }
.mk-dev-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px;
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
}
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
.mk-device-ip {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
width: 100%;
color: var(--neutral-text);
}
.mk-device-ip .mk-ip { font-size: 13px; }
.mk-device-ip .tip-wrap { width: auto; }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
.mk-dev-meta .chip { font-size: var(--body-small-size); }
.mk-dev-traffic { font-family: var(--mono); white-space: pre; }
.mk-free {
border: 1px dashed var(--outline);
border-radius: 14px;
padding: 10px 14px;
font-size: 13px;
color: var(--neutral-dot);
}
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
50/50, действия рядом с большим пальцем ── */
@media (max-width: 640px) {
/* ── телефон и планшет (design.md, часть IV): карточка на ключ, устройства строками, тач-цели ≥ 44px ── */
/* compact и medium (< 840px): карточки вместо таблицы */
@media (max-width: 839px) {
.mk-warn { flex-wrap: wrap; padding: 12px 12px 12px 16px; }
.mk-warn-text { flex-basis: 100%; }
.mk-warn .btn { margin-left: auto; }
.mk-tree { display: flex; }
}
@media (pointer: coarse) {
.mk-tree .mk-more { width: 44px; height: 44px; }
}
@media (max-width: 599px) {
.mk-limit-input { width: 100% !important; }
.mk-root { padding: 12px; }
.mk-devs { margin: 12px -12px -12px; }
.mk-dev { padding: 10px 12px; }
/* родитель: шапка, затем ряд действий на всю ширину */
.mk-master { flex-wrap: wrap; row-gap: 10px; }
.mk-master .actions { width: 100%; gap: 8px; }
.mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
.mk-caret-gap { width: 44px; margin-left: -8px; }
.mk-meter { --mk-w: 112px; height: 24px; }
/* настройки узла: поля и кнопки на всю ширину */
@@ -246,38 +258,6 @@ LICENSE file in the root directory of this source tree.
.mk-setting-ctl .btn { flex: 1; height: 44px; }
.mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; }
.mk-tools .btn { width: 100%; height: 44px; justify-content: center; }
/* дерево уже: отступы поменьше, «ножки» короче */
.mk-children { margin-left: 8px; padding-left: 14px; }
.mk-node { padding: 4px 0; }
.mk-node::before { left: -14px; width: 14px; }
.mk-node:last-child::after { left: -16px; }
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
.mk-device-main { flex: 1 1 0; }
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
.mk-device-id { max-width: 100%; }
.mk-device-traffic { width: 100%; gap: 8px; }
.mk-metric {
flex: 1; flex-direction: column; gap: 2px;
background: var(--surface-bright);
border-radius: 16px; padding: 8px 12px;
}
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
.mk-metric-name::after { content: attr(data-sfx); }
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
/* кнопки действий: контурные, на всю ширину ряда, 44px */
.mk-device .actions { width: 100%; gap: 8px; }
.mk-master .btn-icon, .mk-device .btn-icon {
flex: 1; width: auto; height: 44px;
border: 1px solid var(--outline);
border-radius: var(--r-pill);
}
.mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); }
.mk-free { padding: 12px; font-size: 13px; }
}
/* «Серверы для всех» (BulkServersModal) */
@@ -285,27 +265,25 @@ LICENSE file in the root directory of this source tree.
.bs-list { display: flex; flex-direction: column; gap: 8px; }
.bs-row {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px;
font-size: 13px; color: var(--on-surface);
background: var(--surface-container); border-radius: var(--shape-medium); padding: 8px 8px 8px 14px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
}
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.bs-sub .server-pick-ip { margin-left: 0; }
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
.bs-opt {
min-height: 36px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-surface);
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
border: 1px solid var(--outline); border-radius: var(--shape-full);
cursor: pointer; white-space: nowrap;
}
.bs-opt:hover { background: var(--state-primary); }
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; }
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); cursor: pointer; }
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
@media (max-width: 640px) {
@media (max-width: 599px) {
.bs-row { flex-wrap: wrap; }
.bs-opts { width: 100%; }
.bs-opt { flex: 1; }
}
.bs-modal .btn:disabled { opacity: .5; cursor: default; }
-11
View File
@@ -1,11 +0,0 @@
{
"id": "migrate",
"label": "Перенос",
"icon": "move",
"group": "server",
"order": 1.9,
"chrome": {
"serverBar": false,
"restart": false
}
}
-26
View File
@@ -1,26 +0,0 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
.mv-muted { font-size: 13px; color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-box .btn:disabled { opacity: .5; cursor: default; }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
+106 -154
View File
@@ -2,20 +2,16 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoCopy, IcoTrash } from '../../components/icons';
import { apiFetch, getServerId, openTab, announcePrometheus, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoGear } from '../../components/icons';
import type { MonitorStatus as Status } from '../settings/prometheus';
import LineChart from '../../components/LineChart';
import ServerSelect, { ALL_SERVERS } from '../../components/ServerSelect';
import './monitor.css';
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
// Кнопка «Подключить Prometheus» просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента.
// Вкладка «Монитор»: основные графики со встроенного Prometheus. Ставится и удаляется он в «Настройках»,
// раздел «Prometheus» (tabs/settings/prometheus); пока его нет, здесь — карточка со ссылкой туда.
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
interface Status {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
type Pts = [number, number][];
interface Overview {
range: string; step: number; from: number; to: number;
@@ -27,6 +23,24 @@ interface Overview {
total: Series;
}
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
type Row = Overview['servers'][number];
type NumKey = 'down' | 'up' | 'online' | 'heavy' | 'cpu' | 'mem' | 'cores' | 'mem_total' | 'used_down' | 'used_up';
// «Все серверы» — сводка «сейчас» для плиток: скорости, устройства, ядра, память и объём складываем,
// загрузку процессора и памяти — среднее по серверам (как и ряды total в monitor.go).
function sumUp(rows: Row[]): Row | undefined {
if (rows.length <= 1) return rows[0];
const sum = (k: NumKey) => rows.some(r => r[k] != null) ? rows.reduce((n, r) => n + (r[k] ?? 0), 0) : null;
const avg = (k: NumKey) => {
const v = rows.map(r => r[k]).filter((x): x is number => x != null);
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
};
return {
id: ALL_SERVERS, name: 'Все серверы', ok: rows.every(r => r.ok), congested: rows.some(r => r.congested),
down: sum('down'), up: sum('up'), online: sum('online'), heavy: sum('heavy'), cpu: avg('cpu'), mem: avg('mem'),
cores: sum('cores'), mem_total: sum('mem_total'), used_down: sum('used_down'), used_up: sum('used_up'),
};
}
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
@@ -51,28 +65,6 @@ function Bar({ v, label }: { v: number | null | undefined; label: string }) {
</span>
);
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: Status['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="mon-progress" aria-live="polite">
<span className={`mon-pbar${failed ? ' mon-pbar--bad' : done ? ' mon-pbar--ok' : !step && (starting || job?.running) ? ' mon-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="mon-progress-label">{label}</span>
</div>
);
}
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
function vol(b: number): string {
@@ -81,39 +73,42 @@ function vol(b: number): string {
return `${nf(b / 1e6, 0)} МБ`;
}
export default function MonitorPage({ token, showMsg }: PageProps) {
export default function MonitorPage({ token }: PageProps) {
const [status, setStatus] = useState<Status | null>(null);
const [range, setRange] = useState('1h');
const [ov, setOv] = useState<Overview | null>(null);
const [ovErr, setOvErr] = useState('');
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки
const [server, setServer] = useState<NodeInfo | null>(null); // показанный сервер: выбран в другой вкладке, здесь не меняется
const [nodes, setNodes] = useState<NodeInfo[]>([]);
// Чьи графики: сервер (по умолчанию — выбранный в шапке) или ALL_SERVERS. Выбор свой, шапку не меняет.
const [view, setView] = useState(() => getServerId() ?? 0);
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; if (alive.current) setStatus(s); return s; }
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token]);
const loadOverview = useCallback(async () => {
setLoading(true);
try {
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}&server=${getServerId() ?? 0}`, token) as Overview;
// Без ?server= monitor.go отдаёт все серверы: total — сумма (процессор и память — среднее), servers — каждый
const q = view === ALL_SERVERS ? '' : `&server=${view}`;
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}${q}`, token) as Overview;
if (alive.current) { setOv(o); setOvErr(''); }
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
finally { if (alive.current) setLoading(false); }
}, [token, range]);
}, [token, range, view]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Название и состояние сервера для заголовка; список серверов на этой вкладке не показываем
// Серверы для выбора (и их связь — точки на плашках)
useEffect(() => {
const load = () => apiFetch('GET', '/ui/nodes', token)
.then(r => { if (alive.current) setServer((r.nodes as NodeInfo[]).find(n => n.id === (getServerId() ?? 0)) ?? null); })
.catch(() => { /* заголовок необязателен */ });
.then(r => { if (alive.current) setNodes(r.nodes ?? []); })
.catch(() => { /* выбор необязателен — останется текущий сервер */ });
void load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
@@ -127,116 +122,43 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
return () => clearInterval(t);
}, [status?.ready, loadOverview]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
// Пока Prometheus нет — раз в 10 с проверяем: его могли поставить в «Настройках»
useEffect(() => {
if (!pending && !status?.job?.running) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
if (!status || status.ready) return;
const t = setInterval(() => { void loadStatus(); }, 10000);
return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]);
}, [status, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}?server=${getServerId() ?? 0}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
const working = !!pending || !!status?.job?.running;
// Прогресс рядом с кнопкой: для установки — пока Prometheus нет (там же виден итог удаления), для удаления — только удаление
const progressFor = (action: 'install' | 'remove' | 'any') => {
const j = status?.job;
const mine = !!j && (action === 'any' || j.action === action) && (j.running || j.exit !== null);
const starting = !!pending && (action === 'any' || action === pending);
return mine || starting ? <JobProgress job={mine ? j! : null} starting={starting} /> : null;
};
// Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt
const failureLog = () => {
const j = status?.job;
if (!j || j.exit === null || j.exit === 0) return null;
return (
<>
<pre className="mon-log">{j.log.trim()}</pre>
<p className="login-error">
{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${j.action}`}</b>
</p>
</>
);
};
// ───────── установка ─────────
// Prometheus нет — графиков не будет: ведём в «Настройки», где он ставится
const setup = status && !status.ready && (
<section className="mon-card" aria-labelledby="mon-setup">
<div className="mon-head">
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
<span className="chip chip--offline">
<span className="chip-dot" />{status.job?.running && status.job.action === 'install' ? 'ставится' : 'не подключён'}
</span>
</div>
<p className="mon-hint">
Панель поставит Prometheus {status.host ? <>на сервер <b>{status.host.name}</b></> : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики —
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
только 127.0.0.1, наружу он не открывается.
Графики — скорость, устройства онлайн, нагрузка на канал — строит встроенный Prometheus.
Он ставится в «Настройках», на вкладке «Prometheus»; графики появятся здесь через минуту-две после установки.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
</button>
{progressFor('any')}
</div>
) : (
<>
<p className="mon-hint">
У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё
кнопкой, или выполните на ней от root:
</p>
<code className="mon-code">{status.command}</code>
<div className="mon-actions">
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
</>
)}
{failureLog()}
</section>
);
// Удаление: только пока Prometheus есть
const manage = status?.ready && (
<section className="mon-card" aria-labelledby="mon-manage">
<div className="mon-head">
<h3 className="mon-title" id="mon-manage">Prometheus</h3>
<span className="chip chip--online"><span className="chip-dot" />работает</span>
<div className="mon-actions">
<button className="btn btn--primary" onClick={() => openTab('settings', 'prom')}>
<IcoGear /> Открыть настройки
</button>
</div>
<p className="mon-hint">
Стоит {status.host ? <>на сервере <b>{status.host.name}</b> (панель читает его через агента ноды)</> : 'на этом сервере'} и
слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {pending === 'remove' || (status.job?.running && status.job.action === 'remove') ? 'Удаляем…' : 'Удалить Prometheus'}
</button>
{progressFor('remove')}
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере с Prometheus от root:</p>
<code className="mon-code">awg-ctrl monitor remove</code>
</>
)}
{status.job?.action === 'remove' && failureLog()}
</section>
);
// ───────── дашборд ─────────
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново.
const one = ov?.servers[0];
// Один сервер или сводка по всем (sumUp) — плитки читают одно и то же
const all = view === ALL_SERVERS;
const one = all ? sumUp(ov?.servers ?? []) : ov?.servers[0];
// Кого нет в сумме: сервер не отдал метрики (ok = false) или на связи, а рядов нет совсем (старый агент)
const silent = !all || !ov ? [] : [
...ov.servers.filter(r => !r.ok).map(r => r.name),
...nodes.filter(n => n.online && !ov.servers.some(r => r.id === n.id)).map(n => n.health?.server || n.name),
];
const data = ov?.total ?? null;
const cur = data && {
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
@@ -249,18 +171,30 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
const dashboard = status?.ready && (
<>
{/* Период — единственный фильтр: он задаёт окно для всего ниже */}
<div className="mon-filters" role="group" aria-label="Период">
{RANGES.map(([id, label]) => (
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
))}
{/* Период слева задаёт окно для всего ниже; справа — чьи графики: кнопка и меню, как выбор
сервера в шапке, «Все серверы» — сводка по всем */}
<div className="mon-toolbar">
<div className="mon-filters" role="group" aria-label="Период">
{RANGES.map(([id, label]) => (
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
))}
</div>
{nodes.length > 0 && (
<ServerSelect name="mon-server-menu" nodes={nodes} value={view} onChange={setView} all pickOffline />
)}
</div>
{ovErr && <p className="login-error">{ovErr}</p>}
{ov && !one?.ok && (
{ov && !all && !one?.ok && (
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
)}
{ov && all && (ov.servers.length === 0 || silent.length > 0) && (
<p className="mon-hint mon-notice">
{ov.servers.length === 0 ? 'Метрики сейчас не отдаёт ни один сервер.'
: `Не отдают метрики: ${silent.join(', ')} — в сумме их нет.`}
</p>
)}
<div className="mon-tiles">
<div className="mon-tile">
@@ -303,14 +237,14 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate">
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
<span className="mon-tile-sub">процессор{one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
<span className="mon-tile-sub">процессор{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
</span>
<Bar v={one?.cpu} label="Загрузка процессора" />
</div>
<div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate">
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
<span className="mon-tile-sub">память{one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
<span className="mon-tile-sub">память{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
</span>
<Bar v={one?.mem} label="Занято оперативной памяти" />
</div>
@@ -318,6 +252,33 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
</div>
</div>
{/* Сводка по всем — и разбивка: кто сколько качает и насколько загружен */}
{all && ov && ov.servers.length > 1 && (
<section className="mon-chart mon-per-server" aria-labelledby="mon-per">
<div className="mon-chart-head"><span className="mon-chart-title" id="mon-per">По серверам</span></div>
<div className="mon-table-wrap">
<table className="mon-table">
<thead>
<tr><th>Сервер</th><th>Загрузка</th><th>Отдача</th><th>Онлайн</th><th>Процессор</th><th>Память</th><th>Потрачено</th></tr>
</thead>
<tbody>
{ov.servers.map(r => (
<tr key={r.id}>
<td>{r.name}{!r.ok && <span className="mon-tile-sub"> · нет метрик</span>}</td>
<td>{r.down != null ? rate(r.down) : '—'}</td>
<td>{r.up != null ? rate(r.up) : '—'}</td>
<td>{r.online != null ? nf(r.online, 0) : '—'}</td>
<td>{r.cpu != null ? `${nf(r.cpu, 0)} %` : '—'}</td>
<td>{r.mem != null ? `${nf(r.mem, 0)} %` : '—'}</td>
<td>{r.used_down != null && r.used_up != null ? vol(r.used_down + r.used_up) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{ov && data && (
<div className="mon-charts">
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
@@ -348,18 +309,9 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
return (
<>
{server && (
<p className="mon-server">
<span className="mon-server-label">Сервер</span>
<b>{server.health?.server || server.name}</b>
<span className={`chip chip--${server.online ? 'online' : 'offline'}`}><span className="chip-dot" />{server.online ? 'на связи' : 'нет связи'}</span>
</p>
)}
{!status && <p className="mon-hint">Загрузка…</p>}
{setup}
{dashboard}
{manage}
</>
);
}
+1
View File
@@ -4,6 +4,7 @@
"icon": "pulse",
"group": "server",
"order": 1.7,
"requires": "prometheus",
"chrome": {
"serverBar": false,
"restart": false
+40 -62
View File
@@ -8,25 +8,20 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 12px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
border-radius: var(--shape-extra-large);
padding: 20px;
margin-bottom: 16px;
}
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.mon-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
.mon-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.mon-title { margin: 0; font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-code {
display: block; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
}
@media (max-width: 640px) {
@media (max-width: 599px) {
.mon-actions .btn { flex: 1; }
.mon-toolbar .server-select { flex: 1 1 100%; } /* своя строка под периодом */
.mon-toolbar .server-current { width: 100%; }
.mon-toolbar .server-menu { width: 100%; }
}
/* ───── Графики ─────
@@ -35,30 +30,31 @@ LICENSE file in the root directory of this source tree.
:root { --series-1: #6a45b8; --series-2: #c4620a; }
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
/* Строка над плитками: период слева, выбор сервера (components/ServerSelect) справа; на узком — друг под другом */
.mon-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin-bottom: 16px; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-range {
min-height: 44px; padding: 0 18px;
font: inherit; font-size: 14px; color: var(--on-surface);
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
border: 1px solid var(--outline); border-radius: var(--shape-full);
cursor: pointer;
}
.mon-range:hover { background: var(--state-primary); }
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
.mon-tile {
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-table);
border: 1px solid var(--outline-variant); border-radius: var(--shape-large);
padding: 14px 16px;
}
.mon-tile-label { font-size: 12px; color: var(--neutral-text); }
.mon-tile-value { font-size: 20px; font-weight: 500; color: var(--on-surface); }
.mon-tile-sub { font-size: 12px; color: var(--neutral-text); }
.mon-tile-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-tile-value { font-size: var(--title-large-size); line-height: var(--title-large-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-tile-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; }
.mon-tile-dir { font-size: 16px; }
.mon-tile-dir { font-size: var(--body-large-size); line-height: var(--body-large-line); }
.mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); }
.mon-tile-meter { display: flex; flex-direction: column; gap: 4px; }
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
@@ -71,78 +67,60 @@ LICENSE file in the root directory of this source tree.
.mon-chart {
margin: 0; min-width: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-card);
border: 1px solid var(--outline-variant); border-radius: var(--shape-extra-large);
padding: 16px;
transition: opacity .15s;
transition: opacity var(--duration-short3) var(--ease-standard);
}
.mon-chart--dim { opacity: .6; }
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
.mon-chart-title { font-size: 15px; font-weight: 500; color: var(--on-surface); }
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--neutral-text); }
.mon-chart-title { font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: 2px; flex: none; }
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: var(--shape-extra-small); flex: none; }
.mon-chart-toggle {
margin-left: auto; min-height: 44px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-primary-container);
background: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
font: inherit; font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-primary-container);
background: none; border: 0; border-radius: var(--shape-full); cursor: pointer;
}
.mon-chart-toggle:hover { background: var(--state-primary); }
.mon-plot { position: relative; }
.mon-plot svg { display: block; max-width: 100%; }
.mon-plot svg:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 8px; }
.mon-plot svg:focus-visible { border-radius: var(--shape-small); }
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
.mon-tick { fill: var(--neutral-text); font-size: 12px; font-family: var(--font); }
.mon-tick { fill: var(--neutral-text); font-size: var(--body-small-size); font-family: var(--font); }
.mon-cross { stroke: var(--outline); stroke-width: 1; }
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: 13px; color: var(--neutral-text); }
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.mon-tip {
position: absolute; pointer-events: none; z-index: 2;
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-container);
border: 1px solid var(--outline-variant); border-radius: 12px;
padding: 8px 10px; font-size: 12px; color: var(--neutral-text);
border: 1px solid var(--outline-variant); border-radius: var(--shape-medium);
padding: 8px 10px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
}
.mon-tip-time { color: var(--neutral-text); }
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
.mon-tip-row b { font-weight: 500; font-size: 13px; color: var(--on-surface); }
.mon-tip-row b { font-weight: var(--weight-medium); font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-surface); }
.mon-table-wrap { overflow-x: auto; }
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.mon-table th { text-align: left; font-weight: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; }
.mon-table { width: 100%; border-collapse: collapse; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.mon-table th { text-align: left; font-weight: var(--weight-medium); color: var(--on-surface-variant); padding: 8px 10px; white-space: nowrap; }
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mon-log {
margin: 0; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container); border-radius: var(--r-table);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
.mon-notice { margin-bottom: 12px; }
/* Полоски процессора и памяти в плитках */
.mon-bar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); margin: 2px 0; }
.mon-bar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .3s; }
.mon-bar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); margin: 2px 0; }
.mon-bar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--series-1); transition: width var(--duration-medium2) var(--ease-standard); }
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
/* Какой сервер показан (выбирается в других вкладках) */
.mon-server { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 12px; font-size: 16px; color: var(--on-surface); }
.mon-server b { font-weight: 500; }
.mon-server-label { font-size: 12px; color: var(--neutral-text); }
/* Прогресс операции рядом с кнопкой */
/* Разбивка «По серверам» в режиме «Все серверы» */
.mon-per-server { margin-bottom: 16px; }
/* Кнопка рядом с подписью — по центру строки */
.mon-actions { align-items: center; }
.mon-progress { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
.mon-progress-label { font-size: 12px; color: var(--neutral-text); }
.mon-pbar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); }
.mon-pbar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .4s ease; }
.mon-pbar--ok i { background: var(--success-dot); }
.mon-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.mon-pbar--wait i { width: 35% !important; animation: mon-wait 1.2s ease-in-out infinite; }
@keyframes mon-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .mon-pbar--wait i { animation: none; } .mon-pbar i { transition: none; } }
@@ -8,11 +8,11 @@ LICENSE file in the root directory of this source tree.
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: 13px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 12px;
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
@@ -21,9 +21,9 @@ LICENSE file in the root directory of this source tree.
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); }
.apikey-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-empty {
font-size: 14px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
@@ -31,7 +31,7 @@ LICENSE file in the root directory of this source tree.
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
border-radius: var(--shape-extra-large);
padding: 16px;
display: flex;
flex-direction: column;
@@ -49,10 +49,10 @@ LICENSE file in the root directory of this source tree.
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: 16px;
font-weight: 500;
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
@@ -60,7 +60,7 @@ LICENSE file in the root directory of this source tree.
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: 13px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
word-break: break-all;
}
@@ -68,12 +68,15 @@ LICENSE file in the root directory of this source tree.
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
font-size: var(--body-small-size); line-height: var(--body-small-line);
color: var(--neutral-text);
}
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
/* compact и medium (< 840px): карточки вместо таблицы, на планшете — сеткой */
@media (max-width: 839px) {
.apikey-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.apikey-count, .apikey-empty) { grid-column: 1 / -1; }
}
/* Настройки ключа: какие серверы он видит */
@@ -82,13 +85,13 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
border-radius: var(--shape-large);
padding: 12px 16px 14px;
margin: 6px 0 8px;
}
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.apikey-tools-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.apikey-tools-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
.apikey-card-servers { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
@@ -2,15 +2,15 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../../components/icons';
import ServerPicker, { serverName } from '../../../components/ServerPicker';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// один раз при создании (в БД хранится только хэш). Раздел вкладки «Настройки».
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
// управляет мастер-ключами. По умолчанию новый ключ видит выбранный в панели сервер, набор
// правится в настройках ключа.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
@@ -18,7 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в сервер-баре
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в панели
const load = useCallback(async () => {
try {
@@ -77,7 +77,6 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
return (
<>
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
<div className="toolbar">
<input
className="field"
@@ -194,11 +193,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
@@ -7,33 +7,33 @@ LICENSE file in the root directory of this source tree.
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.au-run { margin-left: auto; }
.au-hint { font-size: 13px; color: var(--neutral-text); }
.au-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.au-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.au-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: 12px; }
.au-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.au-table td, .au-table th { white-space: nowrap; }
/* мобильный: таблица скрыта (.table-card), то же — карточками */
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.au-card .au-pub { max-width: 100%; }
@media (max-width: 640px) { .au-cards { display: flex; } }
@media (max-width: 839px) { .au-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } }
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.au-run { margin-left: 0; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.au-box-title { margin: 0; font-size: 14px; font-weight: 500; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.au-setting-name { font-size: 14px; }
.au-setting-name { font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
.au-max { width: 88px !important; }
.au-select { width: auto !important; }
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 13px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-log .au-pub { max-width: 240px; }
@@ -2,12 +2,12 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../components/icons';
import { serverName } from '../../components/ServerPicker';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../../components/icons';
import { serverName } from '../../../components/ServerPicker';
import './audit.css';
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// Раздел «Аудит» вкладки «Настройки»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
@@ -96,7 +96,7 @@ export default function AuditPage({ token, showMsg }: PageProps) {
</section>
<section className="au-box">
<h4 className="au-box-title">Настройки</h4>
<h4 className="au-box-title">Параметры аудита</h4>
<label className="au-setting">
<span className="au-setting-text">
<span className="au-setting-name">Снимать мёртвых автоматически</span>
+304
View File
@@ -0,0 +1,304 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, timeAgo, BRAND_FALLBACK, SETTINGS_SECTION_KEY, type Brand, type NodeInfo, type PageProps } from '../../lib/shared';
import { useTheme, setThemeMode, type ThemeMode } from '../../lib/theme';
import { IcoCheck, IcoCopy, IcoRefresh, IcoTrash, IcoPlus } from '../../components/icons';
import ApiKeysPage from './apikeys';
import AuditPage from './audit';
import MigratePage from './migrate';
import PrometheusInstall from './prometheus';
import ServerSettings from './server';
import './settings.css';
// Вкладка «Настройки»: настройки самой панели, не конкретного сервера (те — в меню серверов).
// Разделы — по вкладкам MD3 (primary tabs) сверху, виден один:
// Оформление — тема (хранится в браузере, lib/theme).
// Сервер — деление канала и блокировка торрентов сервера из шапки (./server; туда ведёт и шестерёнка
// в меню серверов).
// API-ключи, Аудит, Перенос — бывшие отдельные вкладки меню; живут в подпапках и
// рендерятся здесь целиком (реестр tabs/index.ts их не видит — он ищет на уровень выше).
// Prometheus — установка встроенного Prometheus (./prometheus) и токен «только метрики» для GET /metrics (/ui/metrics-token): выпуск,
// перевыпуск, отзыв. Значение показывается один раз, в БД — только хэш.
// О панели — имя, канал, версия и серверы.
interface MetricsToken {
exists: boolean;
prefix?: string;
created_at?: number;
last_used?: number | null;
per_peer: boolean; // пер-пировые серии (METRICS_PEERS=on в .env)
}
const SECTIONS = [
{ id: 'look', label: 'Оформление' },
{ id: 'server', label: 'Сервер' },
{ id: 'apikeys', label: 'API-ключи' },
{ id: 'prom', label: 'Prometheus' },
{ id: 'audit', label: 'Аудит' },
{ id: 'migrate', label: 'Перенос' },
{ id: 'about', label: 'О панели' },
] as const;
type SectionId = (typeof SECTIONS)[number]['id'];
// Открытый раздел помнится в браузере — чтобы «Аудит» или «Перенос» не искать заново
// (туда же пишет openTab из lib/shared, когда другая вкладка ведёт в нужный раздел)
const indexOf = (id: SectionId) => SECTIONS.findIndex(t => t.id === id);
function savedSection(): SectionId {
try {
const v = localStorage.getItem(SETTINGS_SECTION_KEY);
if (SECTIONS.some(t => t.id === v)) return v as SectionId;
} catch { /* не критично */ }
return 'look';
}
// Фрагмент prometheus.yml для внешнего Prometheus. Адрес — от страницы: в нём уже секретный путь
// панели (UI_PREFIX) или подпапка nginx, поэтому конфиг верен в обоих случаях.
function scrapeConfig(tok: string): string {
const u = new URL('metrics', location.href.split(/[?#]/)[0]);
return [
'scrape_configs:',
' - job_name: vpn-panel',
` scheme: ${u.protocol.replace(':', '')}`,
` metrics_path: ${u.pathname}`,
' authorization:',
` credentials: ${tok}`,
' static_configs:',
` - targets: ['${u.host}']`,
].join('\n');
}
const THEMES: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: 'Светлая' },
{ id: 'dark', label: 'Тёмная' },
{ id: 'system', label: 'Как в системе' },
];
export default function SettingsPage({ token, showMsg }: PageProps) {
const { mode } = useTheme();
const [metrics, setMetrics] = useState<MetricsToken | null>(null);
const [created, setCreated] = useState<string | null>(null); // открытый токен — один раз после выпуска
const [busy, setBusy] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [nodes, setNodes] = useState<NodeInfo[]>([]);
// dir — откуда въезжает раздел: 1 — справа (вкладка правее прежней), -1 — слева, 0 — первый показ
const [view, setView] = useState<{ section: SectionId; dir: number }>(() => ({ section: savedSection(), dir: 0 }));
const section = view.section;
const go = (id: SectionId) => setView(v => v.section === id ? v : { section: id, dir: Math.sign(indexOf(id) - indexOf(v.section)) });
const tabRefs = useRef<Partial<Record<SectionId, HTMLButtonElement | null>>>({});
const indicator = useRef<HTMLSpanElement>(null);
const centered = useRef(false); // первый показ — без анимации прокрутки вкладок
// Индикатор — одна полоса под активной вкладкой: переезжает к ней, а не появляется заново
useLayoutEffect(() => {
const place = () => {
const b = tabRefs.current[section], el = indicator.current;
if (b && el) { el.style.left = `${b.offsetLeft}px`; el.style.width = `${b.offsetWidth}px`; }
};
place();
// На узком экране вкладки прокручиваются — выбранную ставим по центру полосы (у краёв — насколько
// позволяет прокрутка). Двигаем саму полосу, а не scrollIntoView: тот прокрутил бы и страницу.
const b = tabRefs.current[section], bar = b?.parentElement;
if (b && bar && bar.scrollWidth > bar.clientWidth) {
const still = !centered.current || matchMedia('(prefers-reduced-motion: reduce)').matches;
bar.scrollTo({ left: b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2, behavior: still ? 'auto' : 'smooth' });
}
centered.current = true;
try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [section]);
// openTab('settings', раздел) при уже открытых настройках: раздел записан в localStorage — переключаемся на него
useEffect(() => {
const h = (e: Event) => { if ((e as CustomEvent).detail === 'settings') go(savedSection()); };
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Стрелки, Home/End — по вкладкам (MD3 / WAI-ARIA tabs)
const onTabKey = (e: React.KeyboardEvent) => {
const i = indexOf(section);
const j = e.key === 'ArrowRight' ? (i + 1) % SECTIONS.length
: e.key === 'ArrowLeft' ? (i + SECTIONS.length - 1) % SECTIONS.length
: e.key === 'Home' ? 0 : e.key === 'End' ? SECTIONS.length - 1 : -1;
if (j < 0) return;
e.preventDefault();
go(SECTIONS[j].id);
tabRefs.current[SECTIONS[j].id]?.focus();
};
// Адрес /metrics рядом с панелью — с учётом префикса, под которым она открыта
const metricsUrl = new URL('metrics', location.href.split(/[?#]/)[0]).href;
const load = useCallback(async () => {
try {
setMetrics(await apiFetch('GET', '/ui/metrics-token', token));
} catch {
showMsg('Ошибка загрузки настроек');
}
// /ui/brand за авторизацией: без токена 401 «Unauthorized», и App считал сессию истёкшей.
apiFetch('GET', '/ui/brand', token).then(data => setBrand({
brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '',
})).catch(() => {});
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const issue = useCallback(async () => {
if (metrics?.exists && !confirm('Перевыпустить токен? Старый сразу перестанет работать — Prometheus нужно будет перенастроить.')) return;
setBusy(true);
try {
const r = await apiFetch('POST', '/ui/metrics-token', token);
setCreated(r.token);
setMetrics(r);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [metrics, token, showMsg]);
const revoke = useCallback(async () => {
if (!confirm('Отозвать токен? Prometheus перестанет получать метрики панели.')) return;
setBusy(true);
try {
setMetrics(await apiFetch('DELETE', '/ui/metrics-token', token));
showMsg('Токен отозван');
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [token, showMsg]);
const online = nodes.filter(n => n.online).length;
return (
<>
<div className="st-tabs" role="tablist" aria-label="Разделы настроек" onKeyDown={onTabKey}>
{SECTIONS.map(t => (
<button key={t.id} type="button" role="tab" id={`st-tab-${t.id}`} className="st-tab"
aria-selected={section === t.id} aria-controls={`st-panel-${t.id}`}
tabIndex={section === t.id ? 0 : -1}
ref={el => { tabRefs.current[t.id] = el; }} onClick={() => go(t.id)}>
{t.label}
</button>
))}
<span className="st-tabs-indicator" ref={indicator} aria-hidden="true" />
</div>
{/* Раздел пересоздаётся при смене (key) и въезжает со стороны выбранной вкладки */}
<div key={section} className="st-view" role="tabpanel" id={`st-panel-${section}`} aria-labelledby={`st-tab-${section}`}
style={{ '--st-dir': view.dir } as React.CSSProperties}>
{section === 'look' && (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Тема</span>
<span className="st-hint">Запоминается в этом браузере</span>
</span>
<div className="segmented" role="radiogroup" aria-label="Тема">
{THEMES.map(t => (
<button key={t.id} type="button" role="radio" aria-checked={mode === t.id}
className="segmented-item" onClick={() => setThemeMode(t.id)}>
{mode === t.id && <IcoCheck />}{t.label}
</button>
))}
</div>
</div>
</section>
)}
{section === 'prom' && <PrometheusInstall token={token} showMsg={showMsg} />}
{section === 'prom' && (
<section className="st-section">
<p className="st-hint st-lead">
Метрики панели и всех серверов отдаются по адресу <code className="st-code">{metricsUrl}</code> только
с токеном: Prometheus передаёт его заголовком <code className="st-code">Authorization: Bearer</code>.
Токен открывает только метрики — ни панель, ни API.
</p>
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Токен метрик</span>
{metrics?.exists ? (
<span className="st-hint">
<code className="st-code">{metrics.prefix}</code> · выпущен {new Date((metrics.created_at ?? 0) * 1000).toLocaleDateString()}
{' · '}{metrics.last_used ? `скрейп ${timeAgo(metrics.last_used)} назад` : 'ещё не использовался'}
</span>
) : (
<span className="st-hint">{metrics ? 'Не выпущен — /metrics закрыт' : 'Загрузка…'}</span>
)}
</span>
{metrics && (
<span className="st-row-ctl">
{metrics.exists && (
<button className="btn btn--danger" disabled={busy} onClick={revoke}><IcoTrash /> Отозвать</button>
)}
<button className={`btn ${metrics.exists ? 'btn--tonal' : 'btn--primary'}`} disabled={busy} onClick={issue}>
{metrics.exists ? <><IcoRefresh /> Перевыпустить</> : <><IcoPlus /> Выпустить</>}
</button>
</span>
)}
</div>
{metrics && (
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Метрики по пользователям</span>
<span className="st-hint">
{metrics.per_peer
? 'Включены: серии по каждому пиру с внутренним IP и именем'
: 'Выключены. Включаются строкой METRICS_PEERS=on в .env панели'}
</span>
</span>
<span className={`chip chip--${metrics.per_peer ? 'online' : 'offline'}`}>
<span className="chip-dot" />{metrics.per_peer ? 'Вкл' : 'Выкл'}
</span>
</div>
)}
</section>
)}
{section === 'server' && <ServerSettings token={token} showMsg={showMsg} />}
{section === 'apikeys' && <ApiKeysPage token={token} showMsg={showMsg} />}
{section === 'audit' && <AuditPage token={token} showMsg={showMsg} />}
{section === 'migrate' && <MigratePage token={token} showMsg={showMsg} />}
{section === 'about' && (
<section className="st-section">
<dl className="st-facts">
<div><dt>Панель</dt><dd>{brand.brand}</dd></div>
<div><dt>Версия</dt><dd>{[brand.channel, brand.version].filter(Boolean).join(' ') || '—'}</dd></div>
<div><dt>Серверы</dt><dd>{nodes.length ? `${nodes.length}, на связи ${online}` : '—'}</dd></div>
</dl>
</section>
)}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" role="dialog" aria-modal="true" aria-labelledby="st-new-title" onClick={e => e.stopPropagation()}>
<p className="qr-name" id="st-new-title">Токен метрик выпущен</p>
<p className="qr-hint">
Скопируй его сейчас — он показывается один раз. Готовый фрагмент для prometheus.yml:
</p>
<pre className="st-secret st-yml">{scrapeConfig(created)}</pre>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--tonal" onClick={async () => {
await copyText(created);
showMsg('Токен скопирован');
}}><IcoCopy /> Токен</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(scrapeConfig(created));
showMsg('Фрагмент prometheus.yml скопирован');
}}><IcoCopy /> Фрагмент</button>
</div>
</div>
</div>
)}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "settings",
"label": "Настройки",
"icon": "settings",
"group": "server",
"order": 9,
"chrome": {
"restart": false
}
}
@@ -2,11 +2,11 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, type PageProps } from '../../lib/shared';
import { IcoCopy } from '../../components/icons';
import { apiFetch, copyText, type PageProps } from '../../../lib/shared';
import { IcoCopy } from '../../../components/icons';
import './migrate.css';
// Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// Раздел «Перенос» вкладки «Настройки»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
@@ -160,7 +160,7 @@ export default function MigratePage({ token, showMsg }: PageProps) {
</p>
<ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там на вкладке «Перенос» нажмите «Получить код».</li>
<li>Там в «Настройках», на вкладке «Перенос», нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li>
</ol>
{info.state === 'uncertain' ? (
@@ -190,7 +190,7 @@ export default function MigratePage({ token, showMsg }: PageProps) {
info.code ? (
<>
<p className="mv-text">
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует
Вставьте код в «Настройках» старой панели, на вкладке «Перенос». Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p>
<code className="node-modal-cmd">{info.code.code}</code>
@@ -0,0 +1,25 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.mv-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.mv-text { margin: 0; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-muted { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: var(--body-medium-size); line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 599px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
@@ -0,0 +1,160 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, announcePrometheus, type PageProps } from '../../../lib/shared';
import { IcoCopy, IcoPlus, IcoTrash } from '../../../components/icons';
import './prometheus.css';
// Установка встроенного Prometheus (раздел «Prometheus» вкладки «Настройки»; графики — во вкладке «Монитор»).
// Кнопка просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root; сама панель пакетов
// не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента — на сервер,
// выбранный в шапке (при смене сервера вкладка перемонтируется). У панели со своим VPN цель всегда
// «этот сервер».
export interface MonitorStatus {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
on_node?: boolean; // панель на ноде другой панели: Prometheus этой машины ставит и читает та
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
const SETTLE_MS = 90_000;
function JobProgress({ job, starting }: { job: MonitorStatus['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="prom-progress" aria-live="polite">
<span className={`prom-pbar${failed ? ' prom-pbar--bad' : done ? ' prom-pbar--ok' : !step && (starting || job?.running) ? ' prom-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="st-hint">{label}</span>
</div>
);
}
export default function PrometheusInstall({ token, showMsg }: PageProps) {
const target = getServerId() ?? 0; // куда ставить (панель без VPN) — сервер из шапки
const [status, setStatus] = useState<MonitorStatus | null>(null);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена отсюда
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${target}`, token) as MonitorStatus; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token, target]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог. Операция
// закончилась, а панель ещё не видит результата (на ноде агент перезапускается и переподключается) —
// следим дальше, пока ready не совпадёт с ней, но не дольше SETTLE_MS: иначе «Готово» висит рядом
// с «не подключён», а пункт «Монитор» ждёт минутного опроса App.
const [settle, setSettle] = useState<number | null>(null); // до какого момента ждём ready
useEffect(() => {
if (!pending && !status?.job?.running && settle === null) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (!s?.job || s.job.running) return; // агент ноды переподключается — job пока не виден
setPending(null);
const waiting = s.job.exit === 0 && s.ready !== (s.job.action === 'install');
setSettle(d => waiting && (d === null || Date.now() < d) ? d ?? Date.now() + SETTLE_MS : null);
}, 2000);
return () => clearInterval(t);
}, [pending, status?.job?.running, settle, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}?server=${target}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
if (!status) return <section className="st-section"><p className="st-hint">Загрузка…</p></section>;
if (status.on_node) return (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Встроенный Prometheus</span>
<span className="st-hint">Этот сервер подключён нодой к другой панели. Prometheus на нём собирает её метрики, поэтому ставится, удаляется и смотрится («Монитор») из неё, а не отсюда.</span>
</span>
</div>
</section>
);
const job = status.job;
const working = !!pending || !!job?.running || settle !== null;
const doing = pending ?? (job?.running ? job.action : null);
const showJob = !!pending || (!!job && (job.running || job.exit !== null));
const failed = !!job && job.exit !== null && job.exit !== 0;
// «Этот сервер» путал: в шапке может быть выбрана нода, а Prometheus у панели один и стоит не там
const where = status.host ? <>на сервере <b>{status.host.name}</b></> : 'на сервере панели';
const command = status.ready ? 'awg-ctrl monitor remove' : status.command;
return (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Встроенный Prometheus</span>
<span className="st-hint">
{status.ready
? <>Один на всю панель, собирает метрики всех серверов. Стоит {where}{status.host && ' (панель читает его через агента ноды)'}, слушает только 127.0.0.1:9090, графики хранит 31 день.</>
: <>Один на всю панель, соберёт метрики всех серверов. Панель поставит его {where} и сама подключит к своим метрикам: графики «Монитора» появятся через минуту-две. Наружу он не открывается.</>}
</span>
</span>
<span className="st-row-ctl">
<span className={`chip chip--${status.ready ? 'online' : 'offline'}`}>
<span className="chip-dot" />{doing === 'install' ? 'ставится' : doing === 'remove' ? 'удаляется' : settle !== null ? (status.ready ? 'отключается' : 'подключается') : status.ready ? 'работает' : 'не подключён'}
</span>
{status.can_install && (status.ready ? (
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {doing === 'remove' ? 'Удаляем…' : 'Удалить'}
</button>
) : (
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
<IcoPlus /> {doing === 'install' ? 'Устанавливаем…' : 'Установить'}
</button>
))}
</span>
</div>
{showJob && <JobProgress job={job} starting={!!pending} />}
{!status.can_install && (
<div className="st-row">
<span className="st-row-text">
<span className="st-hint">
{status.ready ? 'Удаляется на сервере с Prometheus от root:'
: 'На выбранном сервере установка кнопкой недоступна (нет связи или старый awg-ctrl): выберите другой сервер в шапке или выполните на этом от root:'}
</span>
<code className="prom-code">{command}</code>
</span>
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
)}
{/* Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt */}
{failed && (
<>
<pre className="prom-log">{job!.log.trim()}</pre>
<p className="login-error">
{job!.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${job!.action}`}</b>
</p>
</>
)}
</section>
);
}
@@ -0,0 +1,31 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Установка Prometheus (раздел «Prometheus» вкладки «Настройки»). Строки — из settings.css. */
/* Ход установки или удаления: полоска + подпись шага */
.prom-progress { display: flex; flex-direction: column; gap: 6px; padding: 4px 0 12px; }
.prom-pbar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); }
.prom-pbar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--primary); transition: width var(--duration-medium4) var(--ease-standard); }
.prom-pbar--ok i { background: var(--success-dot); }
.prom-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.prom-pbar--wait i { width: 35% !important; animation: prom-wait 1.2s ease-in-out infinite; }
@keyframes prom-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .prom-pbar--wait i { animation: none; } .prom-pbar i { transition: none; } }
.prom-code {
display: block; margin-top: 6px; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 10px 12px;
}
.prom-log {
margin: 0 0 8px; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest); border-radius: var(--shape-medium);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
@@ -3,17 +3,13 @@
// LICENSE file in the root directory of this source tree.
import { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, type NodeInfo, type ShapingStatus, type TorrentStatus } from '../lib/shared';
import { apiFetch, type PageProps, type ShapingStatus, type TorrentStatus } from '../../../lib/shared';
import './server.css';
// Настройки сервера: деление канала между устройствами (CAKE) и блокировка BitTorrent. Сами
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
token: string;
node: NodeInfo;
onClose: () => void;
showMsg: (text: string) => void;
}) {
// Раздел «Сервер» вкладки «Настройки»: деление канала между устройствами (CAKE) и блокировка BitTorrent
// для сервера из шапки (при его смене вкладка перемонтируется). Сами настройки живут в awg-ctrl этого
// сервера — запрос идёт обычным прокси с X-Server-Id, так что ядро и ноды настраиваются одинаково.
export default function ServerSettings({ token, showMsg }: PageProps) {
const [st, setSt] = useState<ShapingStatus | null>(null);
const [enabled, setEnabled] = useState(false);
const [auto, setAuto] = useState(true);
@@ -63,12 +59,6 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
return () => { alive = false; };
}, [token]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const downN = Number(down), upN = Number(up);
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
@@ -99,18 +89,17 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
};
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">Настройки сервера: {node.name}</h3>
<h4 className="ss-section">Деление канала</h4>
<>
<section className="st-section ss-card">
<h3 className="ss-section">Деление канала</h3>
{unsupported ? (
<p className="node-modal-hint">
<p className="st-hint">
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<p className="node-modal-hint">
<p className="st-hint">
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
</p>
@@ -134,7 +123,7 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
</label>
</div>
<p className="node-modal-hint">
<p className="st-hint">
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
и деление перестаёт работать.
</p>
@@ -142,7 +131,7 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
<span className="node-modal-hint">
<span className="st-hint">
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
</span>
@@ -167,10 +156,12 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</>
)}
<hr className="ss-divider" />
<h4 className="ss-section">Торренты</h4>
</section>
<section className="st-section ss-card">
<h3 className="ss-section">Торренты</h3>
{tUnsupported ? (
<p className="node-modal-hint">
<p className="st-hint">
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
</p>
) : (
@@ -178,12 +169,12 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Блокировать BitTorrent</span>
<span className="node-modal-hint">
<span className="st-hint">
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
</span>
<span className="node-modal-hint">
<span className="st-hint">
{enabled
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
@@ -199,14 +190,14 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</span>
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
{tst.active && !tst.tcp && (
<span className="node-modal-hint">
<span className="st-hint">
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
</span>
)}
{tst.error && <span className="node-modal-hint">{tst.error}</span>}
{tst.error && <span className="st-hint">{tst.error}</span>}
{tst.seen.length > 0 && (
<>
<span className="node-modal-hint">Пытались качать за последние 10 минут:</span>
<span className="st-hint">Пытались качать за последние 10 минут:</span>
<span className="ss-seen">
{tst.seen.map(p => (
<span key={p.ip} className="chip chip--error">
@@ -221,17 +212,17 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</>
)}
{error && <p className="login-error">{error}</p>}
</section>
<div className="node-modal-actions">
{!(unsupported && tUnsupported) && (
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
)}
<button className="btn btn--outline" onClick={onClose}>Закрыть</button>
{error && <p className="login-error">{error}</p>}
{!(unsupported && tUnsupported) && (
<div className="ss-actions">
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,11 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Раздел «Сервер» вкладки «Настройки»: бывший диалог «Настройки сервера», строки .ss-* — из App.css */
.ss-card { gap: 12px; padding: 16px; }
.ss-card .ss-section { margin: 0; }
.ss-card .ss-status { background: var(--surface-container-lowest); }
.ss-actions { display: flex; justify-content: flex-end; }
.ss-card .ss-grid { max-width: 480px; }
+122
View File
@@ -0,0 +1,122 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Настройки». Общая дизайн-система — в App.css (там же .segmented). */
/* Вкладки разделов (MD3 primary tabs, прокручиваемые): 48px, подпись title-small, прижаты влево;
активная — цветом primary и полосой 3px снизу, которая переезжает между вкладками (left/width
ставит компонент). Под вкладками — линия outline-variant во всю ширину. */
.st-tabs {
position: relative;
display: flex;
box-shadow: inset 0 -1px var(--outline-variant); /* линия — тенью: рамка ушла бы в прокрутку */
/* MD3 scrollable tabs: не влезли — прокручиваются вбок, без полосы прокрутки */
overflow-x: auto;
scrollbar-width: none;
}
.st-tabs::-webkit-scrollbar { display: none; }
.st-tab {
flex: 0 0 auto;
height: 48px;
padding: 0 20px;
border: none;
background: transparent;
color: var(--on-surface-variant);
font-family: var(--font);
font-size: var(--title-small-size); line-height: var(--title-small-line);
font-weight: var(--weight-medium);
white-space: nowrap;
cursor: pointer;
transition: color var(--duration-short3) var(--ease-standard);
}
.st-tab[aria-selected="true"] { color: var(--primary); }
.st-tab:focus-visible { outline-offset: -3px; }
.st-tabs-indicator {
position: absolute;
bottom: 0;
height: 3px;
border-radius: 3px 3px 0 0;
background: var(--primary);
transition: left var(--duration-medium2) var(--ease-standard), width var(--duration-medium2) var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) { .st-tabs-indicator { transition: none; } }
/* Содержимое раздела — с тем же шагом, что у страницы. При смене вкладки въезжает на 32px со стороны
выбранной (--st-dir: 1 — справа, -1 — слева, 0 — только проявляется), как MD3 shared axis X.
Только backwards: после анимации transform не остаётся — иначе fixed-диалоги внутри (QR, ключи)
отсчитывались бы от раздела, а не от окна. */
.st-view {
display: flex; flex-direction: column; gap: 20px; min-width: 0;
animation: st-view-in var(--duration-medium2) var(--ease-emphasized-decelerate) backwards;
}
@keyframes st-view-in {
from { opacity: 0; transform: translateX(calc(var(--st-dir, 0) * 32px)); }
}
@media (prefers-reduced-motion: reduce) { .st-view { animation: none; } }
/* въезжающий раздел на миг шире страницы — без горизонтальной прокрутки */
.main:has(.st-view) { overflow-x: hidden; }
/* Раздел — блок на surface-container; строки внутри разделены линией outline-variant */
.st-section {
display: flex; flex-direction: column;
background: var(--surface-container);
border-radius: var(--shape-large);
padding: 12px 16px;
}
.st-lead { margin: 4px 0; }
.st-row {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 8px 16px;
padding: 12px 0;
}
.st-row + .st-row, .st-lead + .st-row { border-top: 1px solid var(--outline-variant); }
.st-row:first-child { padding-top: 4px; }
.st-row:last-child { padding-bottom: 4px; }
.st-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 240px; }
.st-row-name { font-size: var(--body-large-size); line-height: var(--body-large-line); color: var(--on-surface); }
.st-row-ctl { display: inline-flex; flex-wrap: wrap; gap: 8px; }
.st-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); overflow-wrap: anywhere; }
.st-code {
font-family: var(--mono);
font-size: 0.95em;
background: var(--surface-container-highest);
border-radius: var(--shape-extra-small);
padding: 0 4px;
overflow-wrap: anywhere;
}
/* О панели: пары «название — значение» */
.st-facts { display: flex; flex-direction: column; margin: 0; }
.st-facts > div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; }
.st-facts > div + div { border-top: 1px solid var(--outline-variant); }
.st-facts > div:first-child { padding-top: 4px; }
.st-facts > div:last-child { padding-bottom: 4px; }
.st-facts dt { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.st-facts dd { margin: 0; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); text-align: right; }
/* Открытый токен (один раз после выпуска) — как ключ API */
.st-secret {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
/* compact: переключатель темы и кнопки — на всю ширину строки */
@media (max-width: 599px) {
.st-row > .segmented { width: 100%; }
.st-row > .segmented .segmented-item { flex: 1; padding: 0 8px; }
.st-row-ctl { width: 100%; }
.st-row-ctl .btn { flex: 1; }
}
/* Фрагмент prometheus.yml в диалоге токена: переносы строк сохраняются */
.st-yml { white-space: pre-wrap; margin: 0; text-align: left; }
+3 -8
View File
@@ -2,11 +2,11 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode';
import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen,
} from '../../lib/shared';
import { qrDataUrl } from '../../lib/qr';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
@@ -109,12 +109,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const vpnKey = u.vpn_key ?? '';
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
const dataUrl = await QRCode.toDataURL(vpnKey, {
width: 560,
margin: 2,
errorCorrectionLevel: 'L',
});
setQrModal({ name, dataUrl, vpnKey });
setQrModal({ name, dataUrl: qrDataUrl(vpnKey, 'L'), vpnKey });
} catch {
showMsg('Не удалось сгенерировать QR-код');
}
@@ -297,6 +292,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
/>
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setQrModal(null)}>Закрыть</button>
<button className="btn btn--tonal" onClick={async () => {
try {
const conf = await vpnKeyToConf(qrModal.vpnKey);
@@ -305,7 +301,6 @@ export default function UsersPage({ token, showMsg }: PageProps) {
showMsg('Конфиг скачан');
} catch { showMsg('Не удалось декодировать ключ'); }
}}>Скачать .conf</button>
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
</div>
</div>
</div>
+21 -13
View File
@@ -8,9 +8,9 @@ LICENSE file in the root directory of this source tree.
.user-cards-list { display: none; flex-direction: column; gap: 12px; }
.users-count { font-size: 12px; color: var(--neutral-text); }
.users-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.empty-cards {
font-size: 14px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
@@ -18,7 +18,7 @@ LICENSE file in the root directory of this source tree.
.user-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
border-radius: var(--shape-extra-large);
padding: 16px;
display: flex;
flex-direction: column;
@@ -36,10 +36,10 @@ LICENSE file in the root directory of this source tree.
gap: 8px;
min-width: 0;
}
.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.user-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.user-card-name {
font-size: 16px;
font-weight: 500;
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
@@ -52,20 +52,23 @@ LICENSE file in the root directory of this source tree.
gap: 8px;
color: var(--neutral-text);
}
.user-card-ip-text { font-family: var(--mono); font-size: 13px; }
.user-card-ip-text { font-family: var(--mono); font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
/* чипы приоритета и торрентов — рядом с IP, как в карточке устройства мастер-ключа (на телефоне
.tip-wrap по умолчанию во всю ширину и уводил чип на отдельную строку) */
.user-card-ip .tip-wrap { width: auto; }
.user-card-metrics { display: flex; gap: 8px; }
.metric-block {
flex: 1;
background: var(--surface-container);
border-radius: var(--r-table);
border-radius: var(--shape-large);
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label { font-size: 12px; color: var(--neutral-text); }
.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); }
.metric-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.metric-value { font-family: var(--mono); font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.user-card-actions { display: flex; gap: 8px; }
.user-card-actions .btn--outline { flex: 1; }
@@ -73,9 +76,14 @@ LICENSE file in the root directory of this source tree.
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
.user-gen { margin-left: 8px; vertical-align: middle; }
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
@media (max-width: 640px) {
.user-cards-list { display: flex; }
/* compact и medium (< 840px): таблица скрывается (.table-card в App.css), показываются карточки —
в одну колонку на телефоне, сеткой на планшете. */
@media (max-width: 839px) {
.user-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.users-count, .empty-cards) { grid-column: 1 / -1; }
}
@media (max-width: 599px) {
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
}
+164
View File
@@ -0,0 +1,164 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Токены дизайн-системы Material 3 (design.md, часть I). Компоненты берут значения только
отсюда — без литеральных цветов, размеров шрифта, радиусов и длительностей.
Цвета — тёплая палитра Forgetting, разложенная по ролям MD3: роли, которых раньше не было
(secondary, tertiary, лестница surface-container, inverse), достроены в тех же оттенках. */
:root {
/* ── Цвет: роли MD3 ── */
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--inverse-primary: #cbb3ff;
--secondary: #645a73;
--on-secondary: #ffffff;
--secondary-container: #e8dff5;
--on-secondary-container: #4c425a;
--tertiary: #8b5a1f;
--on-tertiary: #ffffff;
--tertiary-container: #ffdcad;
--on-tertiary-container: #7a4a14;
--error: #ba1a1a;
--on-error: #ffffff;
--error-container: #ffdad6;
--on-error-container: #410002;
--surface: #faf6f0;
--surface-dim: #e2d9cc;
--surface-bright: #fdfaf6; /* рабочая зона: карточки, поля, диалоги */
--surface-container-lowest: #ffffff;
--surface-container-low: #f5efe6;
--surface-container: #f0e8dd;
--surface-container-high: #ece2d3;
--surface-container-highest: #e4dac9;
--on-surface: #3a2e22;
--on-surface-variant: #5a4a3a;
--inverse-surface: #3a2e22;
--inverse-on-surface: #fdfaf6;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
/* ── Цвет: статусы (custom colors MD3), тёплый подтон ── */
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-dot: #ba1a1a;
--warn-dot: #e08a2c;
/* ── State layers: непрозрачность слоя MD3 (цвет слоя — цвет содержимого, App.css) ── */
--state-hover: .08;
--state-focus: .10;
--state-pressed: .10;
--state-dragged: .16;
/* недоступные элементы: содержимое и контейнер (on-surface 38% / 12%) */
--disabled-content: color-mix(in srgb, var(--on-surface) 38%, transparent);
--disabled-container: color-mix(in srgb, var(--on-surface) 12%, transparent);
--state-row: rgba(106, 69, 184, .035);
--scrim: rgba(58, 46, 34, .45);
--shadow: rgba(58, 46, 34, .15);
/* ── Типографика: шкала MD3 (размер / интерлиньяж); веса — только 400 и 500.
label-small (11px) не заводим: минимальный размер шрифта — 12px (design.md, 6) ── */
--font: system-ui, -apple-system, 'Inter', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--weight-regular: 400;
--weight-medium: 500;
--headline-small-size: 24px; --headline-small-line: 32px;
--title-large-size: 22px; --title-large-line: 28px; /* emphasized: вес 500 */
--title-medium-size: 16px; --title-medium-line: 24px;
--title-small-size: 14px; --title-small-line: 20px;
--body-large-size: 16px; --body-large-line: 24px;
--body-medium-size: 14px; --body-medium-line: 20px;
--body-small-size: 12px; --body-small-line: 16px;
--label-large-size: 14px; --label-large-line: 20px;
--label-medium-size: 12px; --label-medium-line: 16px;
/* ── Форма: шкала скруглений MD3 ── */
--shape-extra-small: 4px;
--shape-small: 8px;
--shape-medium: 12px;
--shape-large: 16px;
--shape-extra-large: 28px;
--shape-full: 9999px;
/* ── Движение: кривые и длительности MD3 ── */
--ease-standard: cubic-bezier(.2, 0, 0, 1);
--ease-standard-decelerate: cubic-bezier(0, 0, 0, 1);
--ease-standard-accelerate: cubic-bezier(.3, 0, 1, 1);
--ease-emphasized-decelerate: cubic-bezier(.05, .7, .1, 1);
--ease-emphasized-accelerate: cubic-bezier(.3, 0, .8, .15);
--duration-short1: 50ms;
--duration-short2: 100ms;
--duration-short3: 150ms;
--duration-short4: 200ms;
--duration-medium1: 250ms;
--duration-medium2: 300ms;
--duration-medium3: 350ms;
--duration-medium4: 400ms;
/* ── Элевация MD3: уровни 1–3 (тени цвета --shadow) ── */
--elevation-1: 0 1px 2px var(--shadow), 0 1px 3px 1px var(--shadow);
--elevation-2: 0 1px 2px var(--shadow), 0 2px 6px 2px var(--shadow);
--elevation-3: 0 1px 3px var(--shadow), 0 4px 8px 3px var(--shadow);
}
:root[data-theme="dark"] {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--inverse-primary: #6a45b8;
--secondary: #cec1de;
--on-secondary: #352c43;
--secondary-container: #483a55;
--on-secondary-container: #ebddff;
--tertiary: #f2bd7c;
--on-tertiary: #4a2800;
--tertiary-container: #4a3218;
--on-tertiary-container: #ffdcad;
--error: #ffb4ab;
--on-error: #690005;
--error-container: #5c1414;
--on-error-container: #ffb4ab;
/* surface-bright светлее канона MD3 (тон 15 вместо 24): рабочая зона в тёмной теме
чуть приподнята над фоном, а не выбелена */
--surface: #1a1611;
--surface-dim: #1a1611;
--surface-bright: #2b251d;
--surface-container-lowest: #14100b;
--surface-container-low: #201a14;
--surface-container: #251f18;
--surface-container-high: #302820;
--surface-container-highest: #3a332a;
--on-surface: #ede2d4;
--on-surface-variant: #b6a994;
--inverse-surface: #ede2d4;
--inverse-on-surface: #2b251d;
--outline: #4d4334;
--outline-variant: #342c20;
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-dot: #ff5449;
--warn-dot: #ffb76b;
--state-row: rgba(203, 179, 255, .05);
--scrim: rgba(0, 0, 0, .55);
--shadow: rgba(0, 0, 0, .45);
}
+7 -5
View File
@@ -4,6 +4,8 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const api = `http://localhost:${process.env.MOCK_PORT || 8080}`;
export default defineConfig({
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
@@ -12,11 +14,11 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ui': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080',
'/api': api,
'/ui': api,
'/health': api,
'/login': api,
'/logout': api,
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.4.7",
"version": "0.5.3",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+1064 -608
View File
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -198,6 +198,26 @@ func authMsg(nonce string, nodeID int) []byte {
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
}
// preauthMsg — подпись в заголовке запроса на WebSocket: хаб проверяет её до апгрейда и без неё
// отвечает 404, как пустой сервер (internal/ui/hub_preauth.go, там же копия).
func preauthMsg(nodeID int, ts int64) []byte {
return []byte("awg-node-pre-v1\n" + strconv.Itoa(nodeID) + "\n" + strconv.FormatInt(ts, 10))
}
// preauthHeader — подпись ноды (и при первой привязке — секрет с ключом) для запроса на WebSocket.
func (a *Agent) preauthHeader() http.Header {
ts := time.Now().Unix()
h := http.Header{}
h.Set("X-Node-Id", strconv.Itoa(a.c.NodeID))
h.Set("X-Node-Ts", strconv.FormatInt(ts, 10))
h.Set("X-Node-Sig", b64.EncodeToString(ed25519.Sign(a.nodeKey, preauthMsg(a.c.NodeID, ts))))
if s := a.secret(); s != "" {
h.Set("X-Node-Join", s)
h.Set("X-Node-Pub", a.nodePubRaw)
}
return h
}
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
@@ -476,13 +496,20 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
HTTPClient: &http.Client{Transport: tr},
HTTPHeader: a.preauthHeader(),
CompressionMode: websocket.CompressionDisabled,
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
})
dcancel()
if err != nil {
// 404 до апгрейда — хаб не принял подпись (или IP временно заблокирован за неудачи):
// как отказ в auth, переподключение — не чаще раза в минуту.
if resp != nil && resp.StatusCode == http.StatusNotFound {
a.c.Errorf("agent: core отклонил ноду до подключения (неверный ключ, просроченная или использованная ссылка, часы ноды сильно расходятся)")
return true
}
if errors.Is(err, errPin) {
a.c.Errorf("agent: %v", errPin)
} else {
@@ -511,6 +538,9 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
if code == closeAuthFailed {
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
}
if code == 4026 {
a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err)
}
return code == closeAuthFailed
}
v, err := jsjson.Decode(raw)
+42 -2
View File
@@ -165,6 +165,9 @@ func New() *CLI {
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
env: [][2]string{
{"PORT", env("UI_PORT", "8080")},
// Секретный путь панели; _MOVED — путь панели, переехавшей сюда (принимаются оба).
{"UI_PREFIX", env("UI_PREFIX", "")},
{"UI_PREFIX_MOVED", env("UI_PREFIX_MOVED", "")},
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
@@ -182,6 +185,8 @@ func New() *CLI {
{"SUB_HOST", env("SUB_HOST", "")},
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
{"NODE_PORT", env("NODE_PORT", "")},
// Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все).
{"HUB_LEGACY", env("HUB_LEGACY", "")},
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
{"RELAY_FROM", env("RELAY_FROM", "")},
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
@@ -477,11 +482,33 @@ func (c *CLI) serviceLine(name string, menu bool) string {
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
}
// panelURL — адрес панели с секретным путём; без UI_PORT (нода) — пусто.
func panelURL() string {
port := env("UI_PORT", "")
if port == "" {
return ""
}
host := env("SUB_HOST", env("SERVER_IP", "IP"))
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
u := "http://" + host + ":" + port + "/"
if p := env("UI_PREFIX", ""); p != "" {
u += p + "/"
}
return u
}
func (c *CLI) status() {
fmt.Println()
for _, n := range c.all {
fmt.Println(c.serviceLine(n, false))
}
if contains(c.all, "ui") {
if u := panelURL(); u != "" {
fmt.Println(" " + dim("Панель "+u))
}
}
if c.isRunning("awgctrl") {
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
if gen, ok := get(h, "gen").(string); ok && gen != "" {
@@ -611,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool {
// ── Точка входа ─────────────────────────────────────────────────────────────
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>>"
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>|update|reinstall|uninstall|panel [path new|off|<путь>]|log [seen] [поиск] [-n N]|metrics [token|revoke]|hub [legacy on|off|auto]|check>"
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall", "panel", "log", "metrics", "hub", "check"}
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) {
@@ -659,6 +686,19 @@ func Main(args []string) {
c.peersCmd(sub, hasSub, a2, hasA2)
case "monitor":
c.monitorCmd(sub)
case "panel":
c.panelCmd(args[1:])
case "log":
c.logCmd(args[1:])
case "metrics":
c.metricsCmd(args[1:])
case "hub":
c.hubCmd(args[1:])
case "check":
c.checkCmd()
case "update", "reinstall", "uninstall":
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
c.runInstaller(args[0], false)
default:
fmt.Println(usage)
os.Exit(1)
+28 -1
View File
@@ -454,6 +454,9 @@ func (c *CLI) setCredentials(field string) {
if pass != nil {
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
}
if u := panelURL(); u != "" {
fmt.Printf(" %s Панель: %s\n", green("✓"), u)
}
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
}
@@ -488,9 +491,19 @@ func (c *CLI) interactiveMenu() {
%s Сменить логин UI
%s Сменить пароль UI
%s
%s Обновить
%s Переустановить
%s Удалить
%s
%s Адрес панели и секретный путь
%s Журнал подключений
%s Кто когда был в VPN
%s Проверка скрытности
%s
%s Выход
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0"))
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep,
bold("10"), bold("11"), bold("12"), bold("13"), sep, bold("0"))
}
for {
printMenu()
@@ -515,6 +528,20 @@ func (c *CLI) interactiveMenu() {
c.setCredentials("user")
case "6":
c.setCredentials("pass")
case "7":
c.runInstaller("update", true)
case "8":
c.runInstaller("reinstall", true)
case "9":
c.runInstaller("uninstall", true)
case "10":
c.panelMenu()
case "11":
c.logCmd(nil)
case "12":
c.logCmd([]string{"seen"})
case "13":
c.checkCmd()
case "0":
os.Exit(0)
default:
+565
View File
@@ -0,0 +1,565 @@
package cli
// Команды для новых возможностей панели — то же, что в веб-интерфейсе, но с сервера:
// panel [path new|off|<путь>] — адрес панели, секретный путь (UI_PREFIX)
// log [seen] [поиск] [-n N] — журнал VPN-подключений этого сервера, «кто когда был»
// metrics [token|revoke] — токен «только метрики» для своего Prometheus
// hub [legacy on|off|auto] — хаб нод: подпись до апгрейда, кто из нод ещё старый
// check — что на этом сервере выдаёт его сканеру и DPI
import (
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/migrate"
)
// pad — дополнение пробелами по числу символов, не байт: в Printf %-Ns кириллица съезжает.
func pad(s string, n int) string {
if l := len([]rune(s)); l < n {
return s + strings.Repeat(" ", n-l)
}
return s
}
func yellow(s string) string { return "\x1b[33m" + s + cReset }
func (c *CLI) hasPanel() bool { return contains(c.all, "ui") || env("UI_PORT", "") != "" && !c.isNode }
func (c *CLI) hasVPN() bool { return contains(c.all, "awgctrl") }
func (c *CLI) uiDB() (*sql.DB, error) {
f := c.svcEnv("ui", "UI_DB_FILE")
if !exists(f) {
return nil, fmt.Errorf("базы панели нет (%s)", f)
}
return migrate.OpenDB(f)
}
// restartUI — cli.env изменён: панель перезапускается с новым значением, VPN не трогаем.
func (c *CLI) restartUI(key, value string) {
c.setSvcEnv("ui", key, value)
if c.isRunning("ui") {
c.stop("ui")
c.start("ui")
}
}
func when(ts int64) string {
t := time.Unix(ts, 0)
now := time.Now()
switch {
case t.YearDay() == now.YearDay() && t.Year() == now.Year():
return "сегодня " + t.Format("15:04")
case t.Year() == now.Year():
return t.Format("02.01 15:04")
}
return t.Format("02.01.2006 15:04")
}
func dur(s int64) string {
switch {
case s < 60:
return fmt.Sprintf("%d сек", s)
case s < 3600:
return fmt.Sprintf("%d мин", s/60)
case s < 86400:
return fmt.Sprintf("%d ч %d мин", s/3600, s%3600/60)
}
return fmt.Sprintf("%d дн %d ч", s/86400, s%86400/3600)
}
func bytesStr(n float64) string {
switch {
case n < 1<<10:
return fmt.Sprintf("%.0f B", n)
case n < 1<<20:
return fmt.Sprintf("%.1f KB", n/(1<<10))
case n < 1<<30:
return fmt.Sprintf("%.1f MB", n/(1<<20))
}
return fmt.Sprintf("%.2f GB", n/(1<<30))
}
// ── panel ───────────────────────────────────────────────────────────────────
var rePrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
func randPrefix() string {
const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
rand.Read(b)
for i := range b {
b[i] = abc[int(b[i])%len(abc)]
}
return string(b)
}
func (c *CLI) panelCmd(args []string) {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — это нода")
return
}
if len(args) == 0 {
c.showPanel()
return
}
if args[0] != "path" || len(args) < 2 {
errorf(" Usage: awg-ctrl panel [path new|off|<путь>]")
os.Exit(1)
}
c.setPanelPath(args[1])
}
func (c *CLI) showPanel() {
fmt.Printf("\n Панель %s\n", bold(panelURL()))
if p := env("UI_PREFIX", ""); p != "" {
fmt.Println(" " + dim("Секретный путь: снаружи без него — 404. Сменить: awg-ctrl panel path new"))
} else {
fmt.Println(" " + yellow("Без секретного пути: сканер узнаёт панель по странице логина.") + " " + dim("Включить: awg-ctrl panel path new"))
}
if m := env("UI_PREFIX_MOVED", ""); m != "" {
fmt.Println(" " + dim("Принимается и путь переехавшей панели: /"+m+"/"))
}
fmt.Println()
}
// setPanelPath — new (случайный), off (без пути) или свой. Закладки и адрес внешнего API
// со старым путём перестанут работать — предупреждаем.
func (c *CLI) setPanelPath(arg string) {
v := arg
switch arg {
case "new":
v = randPrefix()
case "off":
v = ""
default:
if !rePrefix.MatchString(arg) {
errorf(" Путь: 8–64 символа A–Z a–z 0–9 _ -, или new / off")
os.Exit(1)
}
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "UI_PREFIX", v)); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
os.Setenv("UI_PREFIX", v)
c.restartUI("UI_PREFIX", v)
fmt.Printf("\n %s Панель: %s\n", green("✓"), bold(panelURL()))
fmt.Println(" " + dim("Старый адрес больше не открывается: обновите закладки и адрес внешнего API и Prometheus."))
fmt.Println()
}
// ── log ─────────────────────────────────────────────────────────────────────
var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`)
// ownerName — «устройство · ключ» для пира sen://, если его панель — на этой машине.
func (c *CLI) ownerName(db *sql.DB, owner string) string {
m := reOwner.FindStringSubmatch(owner)
if m == nil || db == nil || m[1] != "" && m[1] != c.localPanelID() {
return owner
}
var label, dev string
db.QueryRow("SELECT label FROM master_keys WHERE id = ?", m[2]).Scan(&label)
db.QueryRow("SELECT device_name FROM devices WHERE id = ?", m[3]).Scan(&dev)
switch {
case dev != "" && label != "":
return dev + " · «" + label + "»"
case label != "":
return "удалённое устройство · «" + label + "»"
}
return "удалённое устройство"
}
func (c *CLI) logCmd(args []string) {
if !c.hasVPN() {
fmt.Println(" VPN на этой машине нет — журнал серверов во вкладке «Подключения» панели")
return
}
seen, limit, q := false, 50, ""
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "seen":
seen = true
case a == "-n" && i+1 < len(args):
n, err := strconv.Atoi(args[i+1])
if err != nil || n <= 0 {
errorf(" -n: число событий")
os.Exit(1)
}
limit, i = min(n, 500), i+1
default:
q = strings.TrimSpace(q + " " + a)
}
}
v := url.Values{"limit": {strconv.Itoa(limit)}}
if q != "" && !seen {
v.Set("q", q)
}
r, err := c.ctrlCall("GET", "/api/connlog?"+v.Encode(), nil)
if err != nil {
errorf(" awg-ctrl недоступен: %v", err)
os.Exit(1)
}
if r.status == 404 {
errorf(" awg-ctrl этой версии журнала не ведёт — обновите сервер: awg-ctrl update")
os.Exit(1)
}
if r.status != 200 {
errorf(" %v", ctrlError(r))
os.Exit(1)
}
db, _ := c.uiDB()
if db != nil {
defer db.Close()
}
who := func(o any) (string, string) {
name, _ := get(o, "name").(string)
ip, _ := get(o, "ip").(string)
if name == "" {
if owner, _ := get(o, "owner").(string); owner != "" {
name = c.ownerName(db, owner)
} else {
pub, _ := get(o, "pub_key").(string)
name = "ключ " + pub[:min(10, len(pub))] + "…"
}
}
return name, ip
}
fmt.Println()
if since, ok := get(r.data, "since").(float64); ok {
fmt.Printf(" %s\n\n", dim(fmt.Sprintf("Журнал ведётся с %s, хранится %v дней. Время отключения — последняя активность (±30 с).",
when(int64(since)), get(r.data, "keep_days"))))
}
if seen {
type row struct {
name, ip string
online bool
last int64
}
var rows []row
peers, _ := get(r.data, "peers").([]any)
for _, p := range peers {
name, ip := who(p)
if q != "" && !strings.Contains(strings.ToLower(name+" "+ip), strings.ToLower(q)) {
continue
}
last, _ := get(p, "last_seen").(float64)
online, _ := get(p, "online").(bool)
rows = append(rows, row{name, ip, online, int64(last)})
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].online != rows[j].online {
return rows[i].online
}
return rows[i].last > rows[j].last
})
for _, x := range rows {
state := dim("не подключался с начала журнала")
switch {
case x.online:
state = green("онлайн")
case x.last > 0:
state = "был " + when(x.last) + dim(" ("+dur(time.Now().Unix()-x.last)+" назад)")
}
fmt.Printf(" %s %s %s\n", pad(x.name, 32), dim(pad(x.ip, 12)), state)
}
if len(rows) == 0 {
fmt.Println(" " + dim("Никого"))
}
fmt.Println()
return
}
events, _ := get(r.data, "events").([]any)
if len(events) == 0 {
fmt.Println(" " + dim("Событий нет"))
}
// Старые сверху: читается как лента.
for i := len(events) - 1; i >= 0; i-- {
e := events[i]
at, _ := get(e, "at").(float64)
name, ip := who(e)
mark, tail := green("→ подключился"), ""
if get(e, "event") == "disconnect" {
mark = dim("← отключился ")
d, _ := get(e, "duration").(float64)
rx, _ := get(e, "rx").(float64)
tx, _ := get(e, "tx").(float64)
tail = dim(fmt.Sprintf(" %s · ↓ %s ↑ %s", dur(int64(d)), bytesStr(tx), bytesStr(rx)))
}
fmt.Printf(" %s %s %s %s%s\n", pad(when(int64(at)), 15), mark, name, dim(ip), tail)
}
fmt.Printf("\n %s\n\n", dim("Больше: awg-ctrl log -n 200 · по имени: awg-ctrl log <имя> · кто когда был: awg-ctrl log seen"))
}
// ── metrics ─────────────────────────────────────────────────────────────────
func (c *CLI) metricsCmd(args []string) {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — токен метрик выдаёт панель")
return
}
db, err := c.uiDB()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer db.Close()
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "":
var prefix string
var created int64
var last sql.NullInt64
if db.QueryRow("SELECT prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&prefix, &created, &last) != nil {
fmt.Print("\n Токена для внешнего Prometheus нет. Выдать: awg-ctrl metrics token\n\n")
return
}
used := "ещё не было"
if last.Valid && last.Int64 > 0 {
used = when(last.Int64)
}
fmt.Printf("\n Токен %s · выдан %s · последний скрейп %s\n", bold(prefix), when(created), used)
fmt.Print(" " + dim("Перевыпустить: awg-ctrl metrics token · отозвать: awg-ctrl metrics revoke") + "\n\n")
case "token":
b := make([]byte, 24)
rand.Read(b)
tok := "awgm_" + b64url.EncodeToString(b)
h := sha256.Sum256([]byte(tok))
if _, err := db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
h[:], tok[:13]+"…", time.Now().Unix()); err != nil {
errorf(" ui.db: %v (панель этой версии ещё не создала таблицу — запустите её)", err)
os.Exit(1)
}
u, _ := url.Parse(panelURL())
fmt.Printf("\n %s Токен (показывается один раз, прежний больше не работает):\n\n %s\n\n", green("✓"), bold(tok))
fmt.Print(" Фрагмент prometheus.yml:\n\n")
fmt.Printf(" scrape_configs:\n - job_name: vpn-panel\n metrics_path: %smetrics\n authorization:\n credentials: %s\n static_configs:\n - targets: ['%s']\n\n",
u.Path, tok, u.Host)
fmt.Print(" " + dim("По голому HTTP токен и метрики идут открытым текстом — скрейпьте через nginx с TLS или по VPN.") + "\n\n")
case "revoke":
db.Exec("DELETE FROM metrics_token WHERE id = 1")
fmt.Printf(" %s Токен отозван\n", green("✓"))
default:
errorf(" Usage: awg-ctrl metrics [token|revoke]")
os.Exit(1)
}
}
// ── hub ─────────────────────────────────────────────────────────────────────
func (c *CLI) hubCmd(args []string) {
if !c.hasPanel() || env("NODE_PORT", "") == "" {
fmt.Println(" Хаб нод на этой машине не включён (NODE_PORT)")
return
}
if len(args) > 0 {
if args[0] != "legacy" || len(args) < 2 || !regexp.MustCompile(`^(on|off|auto)$`).MatchString(args[1]) {
errorf(" Usage: awg-ctrl hub [legacy on|off|auto]")
os.Exit(1)
}
v := args[1]
if v == "auto" {
v = ""
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "HUB_LEGACY", v)); err != nil {
errorf(" %v", err)
os.Exit(1)
}
os.Setenv("HUB_LEGACY", v)
c.restartUI("HUB_LEGACY", v)
fmt.Printf(" %s HUB_LEGACY=%s\n", green("✓"), args[1])
}
db, err := c.uiDB()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer db.Close()
rows, err := db.Query("SELECT id, name, COALESCE(version, ''), COALESCE(last_seen, 0), COALESCE(preauth, 0), pub IS NOT NULL FROM nodes ORDER BY id")
if err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
type node struct {
id int64
name, version string
seen int64
preauth, joined bool
}
var list []node
for rows.Next() {
var n node
if rows.Scan(&n.id, &n.name, &n.version, &n.seen, &n.preauth, &n.joined) == nil {
list = append(list, n)
}
}
rows.Close()
old := 0
fmt.Printf("\n Хаб нод :%s\n\n", env("NODE_PORT", ""))
for _, n := range list {
state := green(pad("с подписью", 28))
if !n.preauth {
state, old = yellow(pad("старый агент — без подписи", 28)), old+1
if !n.joined {
state = dim(pad("ещё не подключалась", 28))
}
}
seen := dim("не подключалась")
if n.seen > 0 {
seen = "был " + when(n.seen)
}
fmt.Printf(" #%-3d %s %s %s %s\n", n.id, pad(n.name, 20), dim(pad(n.version, 12)), state, seen)
}
if len(list) == 0 {
fmt.Println(" " + dim("Нод нет"))
}
mode := env("HUB_LEGACY", "")
fmt.Println()
switch {
case mode == "on":
fmt.Println(" " + yellow("HUB_LEGACY=on: хаб пускает агентов без подписи — сканер узнаёт его по /node/v1"))
case mode == "off":
fmt.Println(" HUB_LEGACY=off: только с подписью. Старые агенты не подключатся, пока ноду не обновят")
case old > 0:
fmt.Println(" " + yellow(fmt.Sprintf("Переходный режим: %d нод(ы) без подписи — хаб пускает и без неё. Обновите их: awg-ctrl update на ноде", old)))
default:
fmt.Println(" " + green("Все ноды с подписью — без неё хаб отвечает 404"))
}
fmt.Print(" " + dim("Принудительно: awg-ctrl hub legacy on|off|auto") + "\n\n")
}
// ── check ───────────────────────────────────────────────────────────────────
// Прежние общие параметры установок до рандомизации — по ним сервер узнаётся DPI.
var legacyAWG = map[string]string{"ListenPort": "47619", "S1": "90", "S2": "45", "H1": "1224800044-2116730834"}
func readConf(file string) map[string]string {
out := map[string]string{}
raw, err := os.ReadFile(file)
if err != nil {
return nil
}
for _, l := range strings.Split(strings.SplitN(string(raw), "[Peer]", 2)[0], "\n") {
if k, v, ok := strings.Cut(l, "="); ok {
out[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return out
}
func certCN(file string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
blk, _ := pem.Decode(raw)
if blk == nil {
return ""
}
crt, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return ""
}
return crt.Subject.CommonName
}
// checkCmd — сводка: что на этом сервере выдаёт его сканеру (активное зондирование) и DPI.
func (c *CLI) checkCmd() {
ok := func(s string) { fmt.Println(" " + green("✓") + " " + s) }
bad := func(s, fix string) { fmt.Println(" " + yellow("⚠") + " " + s + "\n " + dim(fix)) }
fmt.Println()
if c.hasVPN() {
conf := readConf(filepath.Join(env("AWG_CONF_DIR", "/etc/amnezia/amneziawg"), "awg1.conf"))
switch {
case conf == nil:
bad("awg1.conf не прочитан", "запустите от root")
default:
var same []string
for k, v := range legacyAWG {
if conf[k] == v {
same = append(same, k)
}
}
sort.Strings(same)
if len(same) > 0 {
bad("VPN: общие для старых установок значения ("+strings.Join(same, ", ")+") — по ним DPI находит все такие серверы сразу",
"переустановка без сохранения данных (awg-ctrl reinstall) и раздача новых ключей")
} else {
ok("VPN: порт и параметры обфускации свои у этого сервера")
}
if conf["I1"] == "" {
bad("VPN: нет своего I1 — ключи получают общий пакет-маскировку", "awg-ctrl reinstall с сохранением данных выдаст свой — старые ключи продолжат работать")
}
}
}
if c.hasPanel() {
if env("UI_PREFIX", "") != "" {
ok("Панель: секретный путь — снаружи без него 404")
} else {
bad("Панель без секретного пути — сканер узнаёт её по странице логина", "awg-ctrl panel path new")
}
bad("Панель по голому HTTP: пути, JSON и токены видны DPI", "nginx с TLS перед панелью (README, «Панель за nginx») или вход по VPN")
if strings.ToLower(env("SUB_TLS", "off")) == "on" {
ok("Подписка sen:// по HTTPS")
} else {
bad("Подписка sen:// по HTTP: ответы подписаны, но видны DPI", "переустановка с SUB_TLS=on — ссылки придётся выдать заново")
}
if cn := certCN(c.svcEnv("ui", "SUB_TLS_CERT_FILE")); cn == "sen" {
bad("Сертификат подписки с общим именем «sen»", "имя меняется только с ключом — при переустановке без сохранения данных")
}
if env("NODE_PORT", "") != "" {
if cn := certCN(c.svcEnv("ui", "NODE_TLS_CERT_FILE")); cn == "awg-node" {
bad("Сертификат хаба с общим именем «awg-node»", "имя меняется только с ключом — ноды придётся подключить заново")
}
switch strings.ToLower(env("HUB_LEGACY", "")) {
case "on":
bad("Хаб пускает агентов без подписи (HUB_LEGACY=on)", "awg-ctrl hub legacy auto")
case "off":
ok("Хаб: только агенты с подписью")
default:
ok("Хаб: подпись до апгрейда; кто из нод ещё без неё — awg-ctrl hub")
}
}
}
fmt.Print("\n " + dim("Фаервол хостера (панель провайдера) отсюда не виден — открыты ли там лишние порты, проверьте сами.") + "\n\n")
}
// panelMenu — пункт меню: адрес панели и смена секретного пути.
func (c *CLI) panelMenu() {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — это нода")
return
}
c.showPanel()
fmt.Println(" 1) Новый случайный путь 2) Свой путь 3) Без пути Enter — назад")
switch strings.TrimSpace(c.ask(" › ")) {
case "1":
c.setPanelPath("new")
case "2":
if p := strings.TrimSpace(c.ask(" Путь (8–64 символа A–Z a–z 0–9 _ -): ")); p != "" {
c.setPanelPath(p)
}
case "3":
if c.confirmYes(" Без пути сканер снова узнает панель по странице логина. Убрать путь?") {
c.setPanelPath("off")
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package cli
// Обновить, переустановить, удалить — из меню awg-ctrl и командами `awg-ctrl update|reinstall|
// uninstall`. Работу делает install.sh (ACTION=… — без его меню): CLI находит скрипт, кладёт во
// временный файл и передаёт ему управление (exec), чтобы диалог шёл в том же терминале.
//
// Скрипт — только из /tmp: install.sh перед заменой файлов убивает процессы, в командной строке
// которых есть путь установки (kill_related). Запущенный из /opt/awg-control/install.sh — или
// текстом через `bash -c` — он убил бы сам себя.
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// Откуда брать свежий установщик; INSTALLER_URL в cli.env — своё зеркало.
const defaultInstallerURL = "https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh"
var installerActions = map[string]struct{ title, what string }{
"update": {"Обновить", "новая версия; настройки, пользователи и роль сохранятся"},
"reinstall": {"Переустановить", "с нуля; данные можно будет сохранить"},
"uninstall": {"Удалить", "полностью: сервисы, данные, правила фаервола; копию данных предложит сохранить"},
}
// fetchInstaller — текст install.sh: для обновления и переустановки — свежий из сети (иначе
// поставилась бы та же версия), для удаления — копия из установки (работает и без сети).
// Копия — запасной путь и для переустановки.
func (c *CLI) fetchInstaller(action string) ([]byte, string, error) {
local := filepath.Join(c.root, "install.sh")
readLocal := func() ([]byte, string, error) {
b, err := os.ReadFile(local)
return b, local, err
}
if action == "uninstall" {
if b, src, err := readLocal(); err == nil {
return b, src, nil
}
}
url := env("INSTALLER_URL", defaultInstallerURL)
cl := &http.Client{Timeout: 30 * time.Second}
resp, err := cl.Get(url)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err == nil && strings.HasPrefix(string(b), "#!") {
return b, url, nil
}
}
err = fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
if action == "reinstall" {
if b, src, lerr := readLocal(); lerr == nil {
fmt.Printf(" %s\n", dim("Установщик не скачался ("+err.Error()+") — берём копию из установки, версия та же"))
return b, src, nil
}
}
return nil, "", err
}
// runInstaller — подтверждение и exec install.sh с ACTION. Возвращается только при отказе или
// ошибке: после exec процесс CLI заменён установщиком.
func (c *CLI) runInstaller(action string, confirm bool) {
a, ok := installerActions[action]
if !ok {
errorf(" Неизвестное действие: %s", action)
return
}
if os.Geteuid() != 0 {
errorf(" Нужен root: sudo awg-ctrl %s", action)
return
}
if confirm && !c.confirmYes(fmt.Sprintf(" %s — %s. Продолжить?", a.title, a.what)) {
fmt.Println(" Отменено")
return
}
script, src, err := c.fetchInstaller(action)
if err != nil {
errorf(" Не удалось получить установщик: %v", err)
errorf(" %s", dim("Запустите его вручную (README, «Установка») или задайте INSTALLER_URL в "+c.envFile))
return
}
f, err := os.CreateTemp("", "awg-install-*.sh")
if err != nil {
errorf(" %v", err)
return
}
f.Write(script)
f.Close()
fmt.Printf(" %s\n\n", dim("Установщик: "+src))
bash := "/bin/bash"
if p, err := exec.LookPath("bash"); err == nil {
bash = p
}
// Исходное окружение, без cli.env: иначе UI_PORT, SERVER_NAME и прочие сохранённые значения
// установщик принял бы за явно заданные параметры.
environ := append(withoutKey(c.origEnv, "ACTION"), "ACTION="+action)
if err := syscall.Exec(bash, []string{"bash", f.Name()}, environ); err != nil {
errorf(" Не удалось запустить установщик: %v", err)
}
}
func withoutKey(environ []string, key string) []string {
out := make([]string, 0, len(environ)+1)
for _, kv := range environ {
if !strings.HasPrefix(kv, key+"=") {
out = append(out, kv)
}
}
return out
}
+6 -2
View File
@@ -127,7 +127,7 @@ func (c *CLI) writeExport(out, dbFile string) error {
return err
}
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT")))
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT"), env("UI_PREFIX", "")))
if cerr := f.Close(); err == nil {
err = cerr
}
@@ -135,11 +135,12 @@ func (c *CLI) writeExport(out, dbFile string) error {
}
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object {
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int, uiPrefix string) jsjson.Object {
return jsjson.Object{
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
{Key: "ui_prefix", Val: uiPrefix},
}
}
@@ -247,6 +248,9 @@ func (c *CLI) migrateImport(file string) {
fmt.Println()
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
if !migrate.ValidUIPrefix(b.Str("ui_prefix")) && env("UI_PREFIX", "") != "" {
fmt.Println(" " + dim("У старой панели не было секретного пути: её закладкам и внешнему API нужен путь этой — /"+env("UI_PREFIX", "")+"/"))
}
}
// ── cancel (старый сервер) ─────────────────────────────────────────────────
+4 -3
View File
@@ -177,9 +177,10 @@ func (c *CLI) monitorInstall() {
if !reDebian.MatchString(osFamily()) {
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
}
// Нода без своей панели (в том числе сервер, с которого панель переехала): метрики панели
// Prometheus получает от агента, а тот — от core.
viaAgent := c.isNode && !contains(c.all, "ui")
// Нода: метрики панели Prometheus получает от агента, а тот — от core. Так и у ноды со своей
// панелью: Prometheus на машине один, и нужен он core — иначе core его не видит (агент
// отдаёт /prom/… только при AGENT_METRICS_PORT), а панель ноды обходится без «Монитора».
viaAgent := c.isNode
target, newPort := os.Getenv("UI_PORT"), false
if viaAgent {
if target = os.Getenv("AGENT_METRICS_PORT"); target == "" {
+3
View File
@@ -344,8 +344,11 @@ func (s *Server) syncPeers() error {
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
func (s *Server) reloadAwgParams() string {
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
p.I1 = s.getCfg(cfgI1, p.I1)
mtu := ReadMTU(s.confPath())
s.cfgMu.Lock()
s.c.Params = p
s.c.MTU = mtu
s.c.Gen = p.Gen()
s.c.Keepalive = keepaliveByGen[s.c.Gen]
g := s.c.Gen
+287
View File
@@ -0,0 +1,287 @@
package ctrl
// Журнал VPN-подключений: кто, когда подключился и отключился, сколько длилась сессия и сколько
// прошло трафика, и когда каждый пир был на связи последний раз (для «кто давно не подключался»).
//
// У WireGuard нет событий подключения — только время последнего handshake и счётчики байт в
// `awg show dump`. Раз в 30 с сверяем их с открытыми сессиями:
// - пир стал онлайн (handshake моложе 3 мин) — «подключился» со временем этого handshake;
// - пир ушёл из онлайна или с интерфейса — «отключился» со временем последней активности
// (последнего тика, когда менялись счётчики): handshake идёт раз в 2 мин, активность точнее.
// Открытые сессии восстанавливаются из базы, поэтому перезапуск awg-ctrl не плодит дублей.
// Внешний IP клиента не пишем: это уже слежка, а не журнал.
import (
"strconv"
"strings"
"sync"
"time"
)
const (
connlogEvery = 30 * time.Second
connlogKeepS = 90 * 24 * 3600 // столько дней храним события
seenEveryS = 60 // last_seen пишем не чаще раза в минуту на пира
connlogLimit = 500
)
type connSession struct {
since int64
rx0, tx0 float64 // счётчики на начало сессии
lastRx, lastTx float64
active int64 // последний тик, когда менялись счётчики
seenSaved int64
}
type peerIdent struct{ name, owner, ip string }
type ConnLog struct {
s *Server
mu sync.Mutex
open map[string]*connSession
loaded bool
pruned int64
peersFn func() (map[string]peerStat, bool) // подменяется в тестах
}
func newConnLog(s *Server) *ConnLog {
l := &ConnLog{s: s, open: map[string]*connSession{}}
l.peersFn = func() (map[string]peerStat, bool) {
if !s.interfaceStatus().Up {
return nil, false // интерфейс лежит — это не «все отключились», а пауза
}
return s.getPeersData(), true
}
return l
}
func (l *ConnLog) Start() {
go func() {
for {
l.tick(time.Now().Unix())
time.Sleep(connlogEvery)
}
}()
}
// idents — имя пользователя или владелец пира sen:// и внутренний IP по публичному ключу. Строки
// читаем до конца до любых других запросов: соединение с SQLite одно (см. metrics_test.go).
func (l *ConnLog) idents() map[string]peerIdent {
out := map[string]peerIdent{}
if rows, err := l.s.db.Query("SELECT pub_key, name, ip FROM users"); err == nil {
for rows.Next() {
var pub, name, ip string
if rows.Scan(&pub, &name, &ip) == nil {
out[pub] = peerIdent{name: name, ip: ip}
}
}
rows.Close()
}
if rows, err := l.s.db.Query("SELECT pub_key, owner, ip FROM peers"); err == nil {
for rows.Next() {
var pub, owner, ip string
if rows.Scan(&pub, &owner, &ip) == nil {
out[pub] = peerIdent{owner: owner, ip: ip}
}
}
rows.Close()
}
return out
}
// load — открытые сессии (последнее событие пира — «подключился») после перезапуска.
func (l *ConnLog) load(now int64) {
type row struct {
pub string
at, rx, tx int64
seen int64
}
var open []row
rows, err := l.s.db.Query(`SELECT c.pub_key, c.at, COALESCE(c.rx, 0), COALESCE(c.tx, 0), COALESCE(s.at, c.at)
FROM conn_log c
JOIN (SELECT pub_key, MAX(id) AS id FROM conn_log GROUP BY pub_key) last ON last.id = c.id
LEFT JOIN peer_seen s ON s.pub_key = c.pub_key
WHERE c.event = 'connect'`)
if err != nil {
return
}
for rows.Next() {
var r row
if rows.Scan(&r.pub, &r.at, &r.rx, &r.tx, &r.seen) == nil {
open = append(open, r)
}
}
rows.Close()
for _, r := range open {
l.open[r.pub] = &connSession{since: r.at, rx0: float64(r.rx), tx0: float64(r.tx),
lastRx: float64(r.rx), lastTx: float64(r.tx), active: r.seen, seenSaved: r.seen}
}
l.loaded = true
}
func (l *ConnLog) tick(now int64) {
stats, ok := l.peersFn()
if !ok {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if !l.loaded {
l.load(now)
}
who := l.idents()
write := func(event, pub string, at int64, dur *int64, rx, tx float64) {
id := who[pub]
var d any
if dur != nil {
d = *dur
}
l.s.db.Exec("INSERT INTO conn_log (at, event, pub_key, name, owner, ip, duration, rx, tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
at, event, pub, id.name, id.owner, id.ip, d, int64(rx), int64(tx))
}
seen := func(pub string, at int64) {
l.s.db.Exec("INSERT INTO peer_seen (pub_key, at) VALUES (?, ?) ON CONFLICT(pub_key) DO UPDATE SET at = excluded.at", pub, at)
}
closeSession := func(pub string, sess *connSession) {
at := max(sess.active, sess.since)
dur := at - sess.since
// Счётчики сбрасываются при перезапуске интерфейса: тогда считаем от нуля.
rx, tx := sess.lastRx-sess.rx0, sess.lastTx-sess.tx0
if rx < 0 {
rx = sess.lastRx
}
if tx < 0 {
tx = sess.lastTx
}
write("disconnect", pub, at, &dur, rx, tx)
seen(pub, at)
delete(l.open, pub)
}
for pub, st := range stats {
sess := l.open[pub]
switch {
case st.Online && sess == nil:
at := int64(st.LastHandshake)
if at <= 0 || at > now {
at = now
}
write("connect", pub, at, nil, st.Rx, st.Tx)
l.open[pub] = &connSession{since: at, rx0: st.Rx, tx0: st.Tx, lastRx: st.Rx, lastTx: st.Tx, active: now, seenSaved: now}
seen(pub, now)
case st.Online:
if st.Rx != sess.lastRx || st.Tx != sess.lastTx {
sess.lastRx, sess.lastTx, sess.active = st.Rx, st.Tx, now
}
if now-sess.seenSaved >= seenEveryS {
seen(pub, sess.active)
sess.seenSaved = now
}
case sess != nil:
if hs := int64(st.LastHandshake); hs > sess.active && hs <= now {
sess.active = hs
}
if st.Rx > sess.lastRx || st.Tx > sess.lastTx {
sess.lastRx, sess.lastTx = st.Rx, st.Tx
}
closeSession(pub, sess)
}
}
// Пира сняли с интерфейса (удалили пользователя, перевыпустили ключ) посреди сессии.
for pub, sess := range l.open {
if _, ok := stats[pub]; !ok {
closeSession(pub, sess)
}
}
if now-l.pruned >= 3600 {
l.s.db.Exec("DELETE FROM conn_log WHERE at < ?", now-connlogKeepS)
l.pruned = now
}
}
// list — GET /api/connlog: события (новые сверху) и «когда был на связи» по каждому пиру.
//
// ?limit=N (до 500), ?before=<id> — следующая страница, ?q=<строка> — по имени, владельцу, IP.
func (l *ConnLog) list(limit, before int64, q string) (ob, error) {
if limit <= 0 || limit > connlogLimit {
limit = connlogLimit
}
where, args := []string{"1 = 1"}, []any{}
if before > 0 {
where, args = append(where, "id < ?"), append(args, before)
}
if q = strings.TrimSpace(q); q != "" {
like := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(q) + "%"
where = append(where, `(name LIKE ? ESCAPE '\' OR owner LIKE ? ESCAPE '\' OR ip LIKE ? ESCAPE '\')`)
args = append(args, like, like, like)
}
args = append(args, limit)
rows, err := l.s.db.Query("SELECT id, at, event, pub_key, name, owner, ip, duration, rx, tx FROM conn_log WHERE "+
strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
if err != nil {
return nil, err
}
events := []any{}
for rows.Next() {
var id, at int64
var event, pub, name, owner, ip string
var dur, rx, tx *int64
if err := rows.Scan(&id, &at, &event, &pub, &name, &owner, &ip, &dur, &rx, &tx); err != nil {
rows.Close()
return nil, err
}
e := ob{{"id", id}, {"at", at}, {"event", event}, {"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}}
if event == "disconnect" {
e = append(e, kv{"duration", deref(dur)}, kv{"rx", deref(rx)}, kv{"tx", deref(tx)})
}
events = append(events, e)
}
rows.Close()
// Все пиры сервера с последним временем на связи (null — с тех пор, как ведётся журнал, не было).
stats := map[string]peerStat{}
if st, ok := l.peersFn(); ok {
stats = st
}
peers := []any{}
rows, err = l.s.db.Query(`SELECT u.pub_key, u.name, '' AS owner, u.ip, s.at FROM users u LEFT JOIN peer_seen s ON s.pub_key = u.pub_key
UNION ALL
SELECT p.pub_key, '', p.owner, p.ip, s.at FROM peers p LEFT JOIN peer_seen s ON s.pub_key = p.pub_key`)
if err != nil {
return nil, err
}
for rows.Next() {
var pub, name, owner, ip string
var at *int64
if err := rows.Scan(&pub, &name, &owner, &ip, &at); err != nil {
rows.Close()
return nil, err
}
var last any
if at != nil {
last = *at
}
peers = append(peers, ob{{"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip},
{"online", stats[pub].Online}, {"last_seen", last}})
}
rows.Close()
var since any
var first int64
if l.s.db.QueryRow("SELECT MIN(at) FROM conn_log").Scan(&first) == nil && first > 0 {
since = first
}
return ob{{"events", events}, {"peers", peers}, {"since", since}, {"keep_days", connlogKeepS / 86400}}, nil
}
func deref(p *int64) any {
if p == nil {
return nil
}
return *p
}
func queryInt(c *rctx, k string) int64 {
n, _ := strconv.ParseInt(c.r.URL.Query().Get(k), 10, 64)
return n
}
+116
View File
@@ -0,0 +1,116 @@
package ctrl
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/logx"
)
func TestConnLog(t *testing.T) {
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES ('ivan', '10.9.0.2', 'PK_IVAN', 'vpn://x')")
db.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES ('PK_PHONE', '10.9.0.3', 'psk', 'p1/m1/d2', 1)")
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
stats := map[string]peerStat{}
up := true
newLog := func() *ConnLog {
l := newConnLog(s)
l.peersFn = func() (map[string]peerStat, bool) {
cp := map[string]peerStat{}
for k, v := range stats {
cp[k] = v
}
return cp, up
}
return l
}
l := newLog()
peer := func(pub string, online bool, hs, rx, tx float64) {
stats[pub] = peerStat{Online: online, LastHandshake: hs, Rx: rx, Tx: tx}
}
events := func() string {
v, err := l.list(0, 0, "")
if err != nil {
t.Fatal(err)
}
var b []string
for _, e := range v.Get2("events").([]any) {
o := e.(jsjson.Object)
s := o.Get2("event").(string) + " " + o.Get2("name").(string) + o.Get2("owner").(string) + " @" + string(jsjson.MustMarshal(o.Get2("at")))
if o.Get2("event") == "disconnect" {
s += " dur=" + string(jsjson.MustMarshal(o.Get2("duration"))) + " rx=" + string(jsjson.MustMarshal(o.Get2("rx")))
}
b = append([]string{s}, b...) // старые сверху — читать проще
}
return strings.Join(b, "; ")
}
peer("PK_IVAN", false, 0, 0, 0)
peer("PK_PHONE", false, 0, 0, 0)
l.tick(1000)
if got := events(); got != "" {
t.Fatalf("без онлайна событий быть не должно: %s", got)
}
peer("PK_IVAN", true, 1010, 100, 50) // подключился, handshake в 1010
l.tick(1030)
peer("PK_IVAN", true, 1010, 900, 300) // качает
l.tick(1060)
peer("PK_IVAN", true, 1130, 900, 300) // keepalive-handshake без трафика
l.tick(1150)
up = false // интерфейс лёг — это не отключение
l.tick(1180)
up = true
peer("PK_IVAN", false, 1130, 900, 300)
l.tick(1330)
if got, want := events(), "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800"; got != want {
t.Fatalf("сессия:\n got %s\nwant %s", got, want)
}
// Перезапуск awg-ctrl посреди сессии: новый ConnLog поднимает открытую сессию из базы.
peer("PK_PHONE", true, 2000, 10, 10)
l.tick(2010)
l = newLog()
peer("PK_PHONE", true, 2100, 70, 10)
l.tick(2110)
// Пира сняли с интерфейса (удалили устройство) — сессия закрывается.
delete(stats, "PK_PHONE")
l.tick(2140)
want := "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800; connect p1/m1/d2 @2000; disconnect p1/m1/d2 @2110 dur=110 rx=60"
if got := events(); got != want {
t.Fatalf("после перезапуска:\n got %s\nwant %s", got, want)
}
// Счётчики сбросились (перезапуск интерфейса) — трафик сессии считается с нуля, не уходит в минус.
peer("PK_IVAN", true, 3000, 5000, 5000)
l.tick(3010)
peer("PK_IVAN", true, 3100, 40, 40)
l.tick(3110)
peer("PK_IVAN", false, 3100, 40, 40)
l.tick(3400)
if got := events(); !strings.HasSuffix(got, "connect ivan @3000; disconnect ivan @3110 dur=110 rx=40") {
t.Fatalf("сброс счётчиков: %s", got)
}
v, _ := l.list(0, 0, "")
seen := map[string]any{}
for _, p := range v.Get2("peers").([]any) {
o := p.(jsjson.Object)
seen[o.Get2("pub_key").(string)] = o.Get2("last_seen")
}
if seen["PK_IVAN"] != float64(3110) && seen["PK_IVAN"] != int64(3110) {
t.Errorf("last_seen ivan: %v", seen["PK_IVAN"])
}
if f, _ := l.list(0, 0, "ivan"); len(f.Get2("events").([]any)) != 4 {
t.Errorf("поиск по имени: %d событий", len(f.Get2("events").([]any)))
}
}
+20
View File
@@ -100,6 +100,26 @@ func initSchema(db *sql.DB) error {
psk_key TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`,
// Журнал VPN-подключений (connlog.go). name/owner/ip — на момент события: пользователя
// могут удалить, а запись о нём остаётся.
`CREATE TABLE IF NOT EXISTS conn_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
event TEXT NOT NULL,
pub_key TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
duration INTEGER,
rx INTEGER,
tx INTEGER
)`,
"CREATE INDEX IF NOT EXISTS idx_conn_log_at ON conn_log (at)",
"CREATE INDEX IF NOT EXISTS idx_conn_log_pub ON conn_log (pub_key, id)",
`CREATE TABLE IF NOT EXISTS peer_seen (
pub_key TEXT PRIMARY KEY,
at INTEGER NOT NULL
)`,
} {
if _, err := db.Exec(q); err != nil {
+219
View File
@@ -0,0 +1,219 @@
package ctrl
import (
"encoding/binary"
"encoding/hex"
"math/rand/v2"
"regexp"
"strconv"
"strings"
)
// I1 — «сигнатурный» пакет, который клиент шлёт перед рукопожатием, чтобы начало сессии
// выглядело как другой протокол. Сервер I-пакеты отбрасывает (они не совпадают ни с одним
// H-заголовком), поэтому совпадать у сервера и клиента I1 не обязан: выданные vpn:// ключи со
// старым I1 продолжают подключаться, а смена касается только новых конфигов.
//
// Поэтому I1 живёт не в awg1.conf, а в БД awg-ctrl (config.i1): в ядро он не уходит — нет
// риска, что модуль не примет шаблон и интерфейс не поднимется, — и переживает KEEP_DATA вместе
// с базой. Если I1 вписан в awg1.conf руками, на первом старте он и берётся.
//
// Шаблон — один из нескольких правдоподобных (DNS-запрос, начало QUIC Initial, STUN binding
// request) со случайными полями: у каждого сервера свой, общий для всех I1 был бы сигнатурой.
// Только теги <b 0x…> и <r N>: их понимают все клиенты с I1–I5. Идентификаторы транзакций и
// соединений — <r N>, они случайны в каждом пакете, как у настоящих протоколов.
const cfgI1 = "i1"
// Популярные домены, запросы к которым в любой сети выглядят обычно. Заблокированных в РФ
// нет: запрос к ним сам по себе привлекал бы внимание.
var i1Domains = []string{
"google.com", "www.google.com", "youtube.com", "gstatic.com", "googleapis.com",
"apple.com", "icloud.com", "microsoft.com", "bing.com", "live.com", "cloudflare.com",
"amazon.com", "wikipedia.org", "github.com", "yandex.ru", "ya.ru", "vk.com", "mail.ru",
"ok.ru", "avito.ru", "ozon.ru", "wildberries.ru", "gosuslugi.ru", "dzen.ru", "2gis.ru",
}
// GenI1 — случайный I1 из одного из шаблонов.
func GenI1() string {
switch rand.IntN(3) {
case 0:
return i1DNS()
case 1:
return i1QUIC()
default:
return i1STUN()
}
}
func pick[T any](v ...T) T { return v[rand.IntN(len(v))] }
// DNS-запрос: ID — <r 2>, флаги RD, один вопрос A/AAAA/HTTPS, иногда EDNS0 (как у systemd-resolved,
// браузеров и роутеров).
func i1DNS() string {
var b []byte
edns := rand.IntN(2) == 0
ar := uint16(0)
if edns {
ar = 1
}
b = binary.BigEndian.AppendUint16(b, 0x0100) // флаги: стандартный запрос, RD
b = append(b, 0, 1, 0, 0, 0, 0) // QDCOUNT 1, ANCOUNT 0, NSCOUNT 0
b = binary.BigEndian.AppendUint16(b, ar) // ARCOUNT
for _, l := range strings.Split(pick(i1Domains...), ".") { // QNAME
b = append(b, byte(len(l)))
b = append(b, l...)
}
b = append(b, 0)
b = binary.BigEndian.AppendUint16(b, pick[uint16](1, 1, 28, 65)) // QTYPE: A чаще, AAAA, HTTPS
b = append(b, 0, 1) // QCLASS IN
if edns {
b = append(b, 0, 0, 41) // OPT, имя — корень
b = binary.BigEndian.AppendUint16(b, pick[uint16](1232, 4096, 1400))
b = append(b, 0, 0) // расширенный RCODE, версия
b = binary.BigEndian.AppendUint16(b, pick[uint16](0, 0x8000)) // флаг DO
b = append(b, 0, 0) // RDLEN
}
return "<r 2><b 0x" + hex.EncodeToString(b) + ">"
}
// Начало QUIC v1 Initial: длинный заголовок (младшие биты первого байта под header protection —
// случайны), DCID 8 байт, SCID 0 или 8, без токена; остаток до 1200/1250 байт — шифрованная
// полезная нагрузка, её изображает <r N>.
func i1QUIC() string {
scid := pick(0, 8)
total := pick(1200, 1250, 1252)
head := 1 + 4 + 1 + 8 + 1 + scid + 1 + 2
rest := total - head
s := "<b 0x" + hex.EncodeToString([]byte{byte(0xc0 | rand.IntN(16)), 0, 0, 0, 1, 8}) + "><r 8>"
if scid > 0 {
s += "<b 0x08><r 8>"
} else {
s += "<b 0x00>"
}
return s + "<b 0x00" + hex.EncodeToString(binary.BigEndian.AppendUint16(nil, 0x4000|uint16(rest))) + "><r " + strconv.Itoa(rest) + ">"
}
// STUN binding request (RFC 5389): magic cookie, ID транзакции — <r 12>; иногда с
// CHANGE-REQUEST (проверка NAT, RFC 5780).
func i1STUN() string {
attrs := pick([]byte{}, []byte{0, 3, 0, 4, 0, 0, 0, 0})
b := []byte{0, 1}
b = binary.BigEndian.AppendUint16(b, uint16(len(attrs)))
b = append(b, 0x21, 0x12, 0xa4, 0x42)
s := "<b 0x" + hex.EncodeToString(b) + "><r 12>"
if len(attrs) > 0 {
s += "<b 0x" + hex.EncodeToString(attrs) + ">"
}
return s
}
var reI1Tag = regexp.MustCompile(`<(b|r) ([^>]*)>`)
// i1Bytes раскрывает шаблон в байты (<r N> — нули); ok=false для тегов, кроме <b>/<r>.
func i1Bytes(s string) ([]byte, bool) {
var out []byte
rest := strings.TrimSpace(s)
for rest != "" {
m := reI1Tag.FindStringSubmatchIndex(rest)
if m == nil || m[0] != 0 {
return nil, false
}
kind, val := rest[m[2]:m[3]], rest[m[4]:m[5]]
if kind == "b" {
h, err := hex.DecodeString(strings.TrimPrefix(val, "0x"))
if err != nil {
return nil, false
}
out = append(out, h...)
} else {
n, err := strconv.Atoi(val)
if err != nil || n < 0 || n > 65535 {
return nil, false
}
out = append(out, make([]byte, n)...)
}
rest = rest[m[1]:]
}
return out, len(out) > 0
}
// dnsName — имя первого вопроса DNS-пакета.
func dnsName(b []byte) string {
var parts []string
for i := 12; i < len(b) && b[i] != 0; {
n := int(b[i])
if n > 63 || i+1+n > len(b) {
return ""
}
parts = append(parts, string(b[i+1:i+1+n]))
i += 1 + n
}
return strings.Join(parts, ".")
}
// DescribeI1 — под что маскируется I1, для панели: вид (dns, quic, stun, custom, none) и подпись.
func DescribeI1(s string) (kind, label string) {
if strings.TrimSpace(s) == "" {
return "none", "без маскировки"
}
b, ok := i1Bytes(s)
switch {
case !ok:
case len(b) >= 20 && binary.BigEndian.Uint32(b[4:8]) == 0x2112a442 && b[0] == 0 && b[1] == 1:
return "stun", "STUN binding request"
case len(b) >= 6 && b[0]&0xc0 == 0xc0 && binary.BigEndian.Uint32(b[1:5]) == 1:
return "quic", "QUIC Initial, " + strconv.Itoa(len(b)) + " байт"
case len(b) > 12 && b[2]&0x80 == 0 && binary.BigEndian.Uint16(b[4:6]) == 1:
if n := dnsName(b); n != "" {
return "dns", "DNS-запрос к " + n
}
case len(b) > 12 && b[2]&0x80 != 0:
if n := dnsName(b); n != "" {
return "dns", "DNS-ответ от " + n
}
}
return "custom", "свой шаблон"
}
// i1FromConf — I1, вписанный в [Interface] awg1.conf руками (ReadParams без него отдаёт
// значение по умолчанию).
func i1FromConf(confFile string) string {
p := ReadParams(confFile, nil)
if p.I1 == DefaultParams().I1 {
return ""
}
return p.I1
}
// ensureI1 — на старте: I1 этого сервера из БД; нет — из awg1.conf, нет и там — новый случайный.
func (s *Server) ensureI1() string {
if v := s.getCfg(cfgI1, ""); v != "" {
return v
}
v := i1FromConf(s.confPath())
if v == "" {
v = GenI1()
}
s.setCfg(cfgI1, v)
kind, label := DescribeI1(v)
s.log.Info("I1 assigned", "kind", kind, "label", label)
return v
}
// regenI1 — новый случайный I1. Клиенты по sen:// получают его со следующим обновлением
// профиля; vpn:// ключи несут прежний и продолжают работать, пока их не перевыпустят.
func (s *Server) regenI1() string {
v := GenI1()
s.setCfg(cfgI1, v)
s.cfgMu.Lock()
s.c.Params.I1 = v
s.cfgMu.Unlock()
kind, label := DescribeI1(v)
s.log.Info("I1 regenerated", "kind", kind, "label", label)
return v
}
func maskingInfo(i1 string) ob {
kind, label := DescribeI1(i1)
return ob{{"i1", i1}, {"kind", kind}, {"label", label}}
}
+58
View File
@@ -0,0 +1,58 @@
package ctrl
import (
"strings"
"testing"
)
func TestGenI1Templates(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 500; i++ {
v := GenI1()
b, ok := i1Bytes(v)
if !ok {
t.Fatalf("шаблон не разбирается: %q", v)
}
kind, label := DescribeI1(v)
seen[kind] = true
switch kind {
case "dns":
if !strings.HasPrefix(v, "<r 2>") || !strings.HasPrefix(label, "DNS-запрос к ") {
t.Fatalf("DNS: %q → %q", v, label)
}
case "quic":
if n := len(b); n != 1200 && n != 1250 && n != 1252 {
t.Fatalf("QUIC длиной %d: %q", n, v)
}
case "stun":
if len(b) != 20 && len(b) != 28 {
t.Fatalf("STUN длиной %d: %q", len(b), v)
}
default:
t.Fatalf("неожиданный вид %q: %q", kind, v)
}
// Только <b>/<r>, без пробелов вне тегов — так I1 пишут и клиенты AmneziaVPN.
if strings.Contains(strings.NewReplacer("<b ", "", "<r ", "").Replace(v), " ") {
t.Fatalf("лишние пробелы: %q", v)
}
}
for _, k := range []string{"dns", "quic", "stun"} {
if !seen[k] {
t.Fatalf("шаблон %s ни разу не выпал", k)
}
}
}
func TestDescribeI1(t *testing.T) {
cases := map[string]string{
DefaultParams().I1: "DNS-ответ от icloud.com",
"": "без маскировки",
"<c><t>": "свой шаблон",
"<b 0xdeadbeef>": "свой шаблон",
}
for in, want := range cases {
if _, got := DescribeI1(in); got != want {
t.Errorf("DescribeI1(%q) = %q, want %q", in, got, want)
}
}
}
+22
View File
@@ -70,6 +70,28 @@ var AWG31Defaults = [][2]string{
{"MaxHandshakeAttempts", "15-20"},
}
// DefaultMTU — MTU установок до рандомизации; запасное, если в awg1.conf его нет или оно странное.
const DefaultMTU = 1376
// ReadMTU читает MTU из [Interface] awg1.conf: install.sh выбирает его случайно, у каждого
// сервера своё, и клиенты получают его в vpn:// ключе.
func ReadMTU(confFile string) int {
raw, err := os.ReadFile(confFile)
if err != nil {
return DefaultMTU
}
iface := pPeerSplit.Split(string(raw), 2)[0]
m := regexp.MustCompile(`(?m)^\s*MTU\s*=\s*(\d+)`).FindStringSubmatch(iface)
if m == nil {
return DefaultMTU
}
n, _ := strconv.Atoi(m[1])
if n < 1280 || n > 1500 {
return DefaultMTU
}
return n
}
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12.
const minSForHeaderProtection = 12
+14
View File
@@ -37,6 +37,14 @@ func (s *Server) routes() []route {
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
}),
R("GET", "/api/connlog", false, a, func(c *rctx) (result, error) {
v, err := s.connlog.list(queryInt(c, "limit"), queryInt(c, "before"), c.r.URL.Query().Get("q"))
if err != nil {
return result{}, err
}
return ok(v)
}),
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
name, _ := c.obj().Get2("name").(string)
if u, err := s.userGet(name); err != nil {
@@ -300,6 +308,12 @@ func (s *Server) routes() []route {
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
}),
// Маскировка первого пакета (I1): посмотреть и сменить. Путь под /api/ — его пропускает
// awg-agent нод. Интерфейс не трогается: I1 нужен только клиентам (см. i1.go).
R("GET", "/api/masking", false, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.cfg().Params.I1)) }),
R("POST", "/api/masking", true, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.regenI1())) }),
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
if err := s.restartAwg(); err != nil {
return result{}, err
+5 -1
View File
@@ -56,6 +56,7 @@ type Server struct {
shaping *Shaping
torrent *Torrent
connlog *ConnLog
metrics metricsCache
monitor monitorJob
cliPath string
@@ -146,17 +147,19 @@ func New() (*Server, error) {
sp, _ := strconv.Atoi(portStr)
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
params.I1 = s.ensureI1() // I1 — из БД, не из conf (см. i1.go)
gen := params.Gen()
module, tools := readVersions()
s.c = Config{
ServerIP: ip, ServerPort: sp, ServerName: name,
DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9",
DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: ReadMTU(s.confPath()), Subnet: "10.9",
Keepalive: keepaliveByGen[gen], Params: params, Gen: gen,
AwgModule: module, AwgTools: tools,
}
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
s.connlog = newConnLog(s)
return s, nil
}
@@ -185,5 +188,6 @@ func (s *Server) Run() error {
go s.shaping.Apply()
s.shaping.Start()
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
s.connlog.Start()
return <-errc
}
+11
View File
@@ -22,6 +22,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -243,6 +244,11 @@ type Plan struct {
Env [][2]string
}
var reUIPrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
// ValidUIPrefix — годится ли строка в секретный префикс панели (UI_PREFIX).
func ValidUIPrefix(s string) bool { return reUIPrefix.MatchString(s) }
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
@@ -270,6 +276,11 @@ func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan,
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
// Секретный путь старой панели: по нему ходят её закладки и внешний API через ретранслятор.
// Свой префикс новая панель не меняет (иначе потеряла бы открытую вкладку) — принимает оба.
if px := b.Str("ui_prefix"); ValidUIPrefix(px) {
p.Env = append(p.Env, [2]string{"UI_PREFIX_MOVED", px})
}
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
if ip := b.Str("server_ip"); ip != "" {
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
+78
View File
@@ -0,0 +1,78 @@
package ui
// Журнал VPN-подключений (вкладка «Подключения»): события ведёт awg-ctrl каждого сервера
// (internal/ctrl/connlog.go), панель забирает их у выбранного сервера и подставляет имена, которых
// awg-ctrl не знает: пир sen:// там — только владелец «<панель>/m<ключ>/d<устройство>».
import (
"net/url"
"regexp"
"strconv"
"github.com/maeneko/forgetting/internal/jsjson"
)
var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`)
// ownerNames — название мастер-ключа и имя устройства для владельца пира. foreign — пир другой
// панели на том же awg-ctrl (у ноды может быть своя): её имён мы не знаем.
func (s *Sub) ownerNames(owner string, cache map[string][3]any) (key, device any, foreign bool) {
if v, ok := cache[owner]; ok {
return v[0], v[1], v[2].(bool)
}
m := reOwner.FindStringSubmatch(owner)
switch {
case m == nil, m[1] != "" && m[1] != s.panelID: // не наш формат или чужая панель
foreign = true
default:
mid, _ := strconv.ParseInt(m[2], 10, 64)
did, _ := strconv.ParseInt(m[3], 10, 64)
var label, name string
if s.a.db.QueryRow("SELECT label FROM master_keys WHERE id = ?", mid).Scan(&label) == nil {
key = label
}
if s.a.db.QueryRow("SELECT device_name FROM devices WHERE id = ?", did).Scan(&name) == nil {
device = name
}
}
cache[owner] = [3]any{key, device, foreign}
return key, device, foreign
}
func (a *App) connlog(c *Ctx) bool {
q := url.Values{}
for _, k := range []string{"limit", "before", "q"} {
if v := c.R.URL.Query().Get(k); v != "" {
q.Set(k, v)
}
}
r, err := a.nodes.CtrlFor(a.serverIDOf(c))("GET", "/api/connlog?"+q.Encode(), nil)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
if r.Status == 404 {
return c.Error(501, "awg-ctrl на этом сервере старой версии и журнала не ведёт — обновите сервер")
}
data, ok := r.Data.(jsjson.Object)
if r.Status != 200 || !ok {
return c.JSON(r.Status, r.Data)
}
cache := map[string][3]any{}
enrich := func(list any) {
items, _ := list.([]any)
for i, it := range items {
o, ok := it.(jsjson.Object)
if !ok {
continue
}
if owner, _ := o.Get2("owner").(string); owner != "" && a.sub != nil {
key, dev, foreign := a.sub.ownerNames(owner, cache)
o = append(o, jsjson.KV{Key: "key", Val: key}, jsjson.KV{Key: "device", Val: dev}, jsjson.KV{Key: "foreign", Val: foreign})
}
items[i] = o
}
}
enrich(data.Get2("events"))
enrich(data.Get2("peers"))
return c.JSON(200, data)
}
+30
View File
@@ -0,0 +1,30 @@
package ui
import "testing"
// Владелец пира sen:// в журнале → название мастер-ключа и устройства этой панели.
func TestConnLogOwnerNames(t *testing.T) {
a := testApp(t, t.TempDir(), "on")
s := &Sub{a: a}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
s.panelID = "abcd1234" // initSchema заводит свой
a.db.Exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)")
a.db.Exec("INSERT INTO devices (id, master_id, device_id, device_name, pub_key, auth_pub, created_at) VALUES (2, 1, 'd', 'iPhone Маши', 'W', 'A', 1)")
cache := map[string][3]any{}
for owner, want := range map[string][3]any{
"abcd1234/m1/d2": {"Семья", "iPhone Маши", false},
"m1/d2": {"Семья", "iPhone Маши", false}, // выдан до областей панели
"abcd1234/m1/d9": {"Семья", nil, false}, // устройство удалено
"abcd1234/m7/d9": {nil, nil, false}, // и ключ удалён
"ffff0000/m1/d2": {nil, nil, true}, // другая панель на том же awg-ctrl
"что-то/другое": {nil, nil, true},
} {
k, d, f := s.ownerNames(owner, cache)
if k != want[0] || d != want[1] || f != want[2] {
t.Errorf("%s: %v %v %v, ждали %v", owner, k, d, f, want)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package ui
// Подпись агента ДО апгрейда на WebSocket (stealth хаба нод). Раньше хаб принимал WebSocket у
// любого и сразу слал hello с nonce: путь /node/v1 есть в открытом коде, и сканер, знающий
// протокол, узнавал хаб одним подключением. Теперь агент кладёт подпись своим ключом в заголовки
// запроса, хаб проверяет её до Accept и без неё отвечает 404 пустого Go-сервера.
//
// Переходный режим: агенты до этой версии заголовков не шлют. Их пускают по-старому, пока в
// панели есть нода, ни разу не подключавшаяся с подписью (nodes.preauth = 0): обновили все —
// путь без подписи закрывается сам. HUB_LEGACY=on / off — принудительно.
//
// Внутри WebSocket протокол прежний (hello → auth → ready): кадр auth обязан быть от той же ноды.
import (
"crypto/ed25519"
"crypto/sha256"
"crypto/subtle"
"net/http"
"os"
"strconv"
"strings"
)
// Часы ноды могут расходиться с панелью: окно широкое. Повтор подписи не страшен — заголовки
// идут внутри TLS с закреплённым сертификатом, подсмотреть их нельзя.
const preauthWindow = 24 * 3600
// preauthMsg — что агент подписывает в заголовке X-Node-Sig. Дублируется в internal/agent.
func preauthMsg(nodeID, ts int64) []byte {
return []byte("awg-node-pre-v1\n" + strconv.FormatInt(nodeID, 10) + "\n" + strconv.FormatInt(ts, 10))
}
// joinOK — одноразовый секрет первой привязки ноды верен и не просрочен.
func joinOK(row *nodeRow, secret string) bool {
if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || secret == "" {
return false
}
h := sha256.Sum256(fromB64url(secret))
return len(row.JoinHash) == len(h) && subtle.ConstantTimeCompare(h[:], row.JoinHash) == 1
}
// preauth — подпись из заголовков. present — агент её прислал (новая версия); row — нода, если
// подпись верна.
func (n *Nodes) preauth(r *http.Request) (row *nodeRow, present bool) {
idS, tsS, sig := r.Header.Get("X-Node-Id"), r.Header.Get("X-Node-Ts"), r.Header.Get("X-Node-Sig")
if idS == "" && tsS == "" && sig == "" {
return nil, false
}
id, err1 := strconv.ParseInt(idS, 10, 64)
ts, err2 := strconv.ParseInt(tsS, 10, 64)
if err1 != nil || err2 != nil || sig == "" {
return nil, true
}
if d := ts - now(); d > preauthWindow || d < -preauthWindow {
warnf("nodes: подпись ноды #%d вне окна: часы расходятся на %d с", id, d)
return nil, true
}
row = n.row(id)
if row == nil {
return nil, true
}
pub := row.Pub.String
if !row.Pub.Valid {
// Первое подключение: ключа ноды ещё нет, его приносит агент вместе с секретом привязки.
if !joinOK(row, r.Header.Get("X-Node-Join")) {
return nil, true
}
pub = r.Header.Get("X-Node-Pub")
}
key := fromB64url(pub)
if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, preauthMsg(id, ts), fromB64url(sig)) {
return nil, true
}
return row, true
}
// legacyOpen — пускать ли подключение без подписи (агент старой версии).
func (n *Nodes) legacyOpen() bool {
switch strings.ToLower(os.Getenv("HUB_LEGACY")) {
case "on":
return true
case "off":
return false
}
var waiting int
n.a.db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE preauth = 0
AND (pub IS NOT NULL OR (join_hash IS NOT NULL AND join_expires >= ?))`, now()).Scan(&waiting)
return waiting > 0
}
+224
View File
@@ -0,0 +1,224 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/agent"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Хаб нод: WebSocket — только агенту с подписью в заголовках; остальным 404 пустого сервера.
// Старых агентов (без подписи) пускают, пока не все ноды подключались по-новому.
func TestHubPreauth(t *testing.T) {
p := newMovePanel(t, "on", "")
pub3, priv3, _ := ed25519.GenerateKey(rand.Reader)
_, stranger, _ := ed25519.GenerateKey(rand.Reader)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub3)); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
hdr := func(id int64, key ed25519.PrivateKey, ts int64) http.Header {
h := http.Header{}
h.Set("X-Node-Id", strconv.FormatInt(id, 10))
h.Set("X-Node-Ts", strconv.FormatInt(ts, 10))
h.Set("X-Node-Sig", b64url.EncodeToString(ed25519.Sign(key, preauthMsg(id, ts))))
return h
}
// dial — код ответа на апгрейд (101 — WebSocket принят) и первый кадр.
dial := func(h http.Header) (int, *websocket.Conn) {
ws, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
if err != nil {
if resp == nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 404 && string(body) != "404 page not found\n" {
t.Errorf("тело 404 не как у пустого сервера: %q", body)
}
return resp.StatusCode, nil
}
return 101, ws
}
read := func(ws *websocket.Conn) jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o
}
auth := func(ws *websocket.Conn, id int64, key ed25519.PrivateKey) jsjson.Object {
nonce := read(ws).Get2("nonce").(string)
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: id},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, authMsg(nonce, id)))}, {Key: "version", Val: MinNodeVersion}}))
return read(ws)
}
now := time.Now().Unix()
if code, _ := dial(hdr(3, stranger, now)); code != 404 {
t.Errorf("чужая подпись: %d, ждали 404", code)
}
if code, _ := dial(hdr(99, stranger, now)); code != 404 {
t.Errorf("несуществующая нода: %d, ждали 404", code)
}
if code, _ := dial(hdr(3, priv3, now-2*preauthWindow)); code != 404 {
t.Errorf("подпись вне окна: %d, ждали 404", code)
}
// Переходный режим: нода 3 ещё не подключалась с подписью — старый агент проходит.
code, ws := dial(nil)
if code != 101 {
t.Fatalf("старый агент в переходном режиме: %d", code)
}
if r := auth(ws, 3, priv3); r.Get2("t") != "ready" {
t.Fatalf("старый агент: %v", r)
}
ws.CloseNow()
// Новый агент: подпись в заголовках, затем прежний auth внутри.
code, ws = dial(hdr(3, priv3, now))
if code != 101 {
t.Fatalf("новый агент: %d", code)
}
if r := auth(ws, 3, priv3); r.Get2("t") != "ready" {
t.Fatalf("новый агент: %v", r)
}
ws.CloseNow()
// Все ноды подключались с подписью — без неё больше не пускают.
if code, _ := dial(nil); code != 404 {
t.Errorf("без подписи после перехода: %d, ждали 404", code)
}
resp, err := http.Get(srv.URL + "/node/v1")
if err != nil || resp.StatusCode != 404 {
t.Errorf("обычный GET: %v %v", resp, err)
}
t.Setenv("HUB_LEGACY", "on")
if code, _ := dial(nil); code != 101 {
t.Errorf("HUB_LEGACY=on: %d", code)
}
t.Setenv("HUB_LEGACY", "")
// Подпись одной ноды, auth от имени другой — отказ.
pub5, priv5, _ := ed25519.GenerateKey(rand.Reader)
p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at, preauth) VALUES (5, 'Riga', ?, 1, 1)", b64url.EncodeToString(pub5))
code, ws = dial(hdr(3, priv3, now+1))
if code != 101 {
t.Fatalf("нода 3: %d", code)
}
if r := auth(ws, 5, priv5); r != nil {
t.Errorf("auth чужой ноды после подписи ноды 3 принят: %v", r)
}
ws.CloseNow()
}
// Настоящий агент привязывается к настоящему хабу по строке join и подключается снова уже
// своим ключом — в обоих случаях с подписью до апгрейда.
func TestHubAgentEndToEnd(t *testing.T) {
t.Setenv("HUB_LEGACY", "off")
p := newMovePanel(t, "on", "")
secretRaw := make([]byte, 16)
rand.Read(secretRaw)
secret := b64url.EncodeToString(secretRaw)
h := sha256.Sum256(secretRaw)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (7, 'Новая', ?, ?, 1)",
h[:], time.Now().Unix()+600); err != nil {
t.Fatal(err)
}
srv := httptest.NewTLSServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
pin := certPin(t, srv.Certificate().Raw)
dir := t.TempDir()
_, ik, _ := ed25519.GenerateKey(rand.Reader)
der, _ := x509.MarshalPKCS8PrivateKey(ik)
os.WriteFile(filepath.Join(dir, "internal.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
env := filepath.Join(dir, "cli.env")
os.WriteFile(env, []byte("JOIN_SECRET="+secret+"\nNODE_ID=7\n"), 0o600)
run := func() {
a, err := agent.New(agent.Config{
CoreHost: u.Hostname(), CorePort: port, CorePin: pin, NodeID: 7, Ctrl: "http://127.0.0.1:1",
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
CLIEnvFile: env, JoinSecret: envValue(env, "JOIN_SECRET"), Version: MinNodeVersion,
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.Run(ctx)
for range 100 {
if p.a.nodes.IsOnline(7) {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("агент не подключился")
}
run() // привязка по секрету
row := p.a.nodes.row(7)
if !row.Pub.Valid {
t.Fatal("ключ ноды не сохранён после привязки")
}
var pre int
p.a.db.QueryRow("SELECT preauth FROM nodes WHERE id = 7").Scan(&pre)
if pre != 1 {
t.Error("подключение с подписью не отмечено (preauth)")
}
for range 100 { // прежнее соединение снимается при отмене контекста
if !p.a.nodes.IsOnline(7) {
break
}
time.Sleep(20 * time.Millisecond)
}
if strings.Contains(envValue(env, "JOIN_SECRET"), secret) {
t.Error("секрет привязки не стёрт после первого подключения")
}
run() // повторное подключение — уже своим ключом
}
func certPin(t *testing.T, raw []byte) string {
cert, err := x509.ParseCertificate(raw)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:])
}
func envValue(file, key string) string {
raw, _ := os.ReadFile(file)
for _, l := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(l, key+"="); ok {
return v
}
}
return ""
}
+13 -1
View File
@@ -211,6 +211,18 @@ func (m *Migration) newCode(c *Ctx) bool {
return m.status(c)
}
// awaitsImport — запрос на приём архива, пока выдан код: старая панель шлёт его на
// /migrate/v1/import без секретного префикса (в коде переноса его нет), prefixGate пропускает.
// Без кода путь снаружи не виден, как и остальные.
func (m *Migration) awaitsImport(r *http.Request) bool {
if r.Method != "POST" || r.URL.Path != "/migrate/v1/import" {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
return m.key != nil && time.Now().Before(m.expires)
}
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
func (m *Migration) importHandler(c *Ctx) bool {
@@ -420,7 +432,7 @@ func (m *Migration) exportMeta() jsjson.Object {
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
{Key: "node_port", Val: m.a.nodes.port},
{Key: "node_port", Val: m.a.nodes.port}, {Key: "ui_prefix", Val: os.Getenv("UI_PREFIX")},
}
}
+13 -3
View File
@@ -15,6 +15,7 @@ import (
"math"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
@@ -81,6 +82,7 @@ type Monitor struct {
a *App
promURL string
localEnabled bool
onNode bool // панель на ноде другой панели: Prometheus этой машины собирает метрики core, не наши
http *http.Client
srcMu sync.Mutex
@@ -102,6 +104,7 @@ func newMonitor(a *App) *Monitor {
return &Monitor{
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
localEnabled: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
onNode: os.Getenv("CORE_HOST") != "",
http: &http.Client{Transport: &http.Transport{Proxy: nil}},
}
}
@@ -132,7 +135,7 @@ func (m *Monitor) forgetSource() {
}
func (m *Monitor) findSource() *promSource {
if m.promReady() {
if !m.onNode && m.promReady() {
return &promSource{local: true}
}
ids := m.a.nodes.IDs()
@@ -316,6 +319,8 @@ func (m *Monitor) target(src *promSource, qs url.Values) int {
switch {
case src != nil && !src.local:
return src.node
case m.onNode:
return -1 // ставит его core: кнопка здесь поставила бы Prometheus для core
case m.localEnabled:
return 0
case src != nil:
@@ -364,15 +369,20 @@ func (m *Monitor) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool {
src := m.source()
ready := src != nil
target := m.target(src, c.R.URL.Query())
job := m.jobState(target)
// Установщик выходит, только когда Prometheus ответил, но проверка выше могла пройти
// до этого: тогда страница получила бы «готово» вместе с ready=false и перестала следить.
if src == nil && !job.running && job.action == "install" && job.exit != nil && jsToNumberAny(job.exit) == 0 {
src = m.source()
}
ready := src != nil
var jobOut any
if job.can && truthy(job.action) {
jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}}
}
return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut},
{Key: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}})
{Key: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}, {Key: "on_node", Val: m.onNode}})
})
// Установка и удаление — одно и то же: просим awg-ctrl сервера, он запускает CLI от root.
ask := func(action string) handler {
+1 -1
View File
@@ -91,7 +91,7 @@ func TestPrometheusOnNodeZero(t *testing.T) {
ag, err := agent.New(agent.Config{
CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0,
Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"),
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile,
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, Version: MinNodeVersion,
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
+89 -17
View File
@@ -15,7 +15,6 @@ import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"database/sql"
"fmt"
@@ -153,14 +152,15 @@ func newNodes(a *App) (*Nodes, error) {
}
return filepath.Join(a.baseDir, name)
}
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Имя в нём случайное:
// одинаковое у всех серверов было бы сигнатурой для сканера (pin — по ключу, не по имени). Живёт столько же,
// сколько core: пересоздать = переподключить все ноды.
if n.port != 0 {
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
if err := func() error {
if !exists(keyF) && !exists(crtF) {
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node")
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN="+randomCN())
if err := cmd.Run(); err != nil {
return err
}
@@ -215,6 +215,13 @@ func newNodes(a *App) (*Nodes, error) {
return nil, err
}
}
// preauth — нода хоть раз подключилась с подписью в заголовках (hub_preauth.go): пока такие
// есть не все, хаб пускает и старых агентов без подписи.
if !hasColumn(a.db, "nodes", "preauth") {
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN preauth INTEGER NOT NULL DEFAULT 0"); err != nil {
return nil, err
}
}
// Нода №0 и локальный сервер — один и тот же id: вместе их не бывает.
if n.local && n.row(0) != nil {
return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " +
@@ -473,21 +480,64 @@ func (n *Nodes) dropConn(id int64, c *nodeConn) {
n.mu.Unlock()
}
// MinNodeVersion — самая старая версия ноды, с которой работает панель (в UI — MIN_NODE_VERSION).
const MinNodeVersion = "0.5.0"
// closeOutdated — код закрытия для ноды старше MinNodeVersion.
const closeOutdated = 4026
// cmpVersion сравнивает версии вида 0.5.0 (хвост после «-» не учитывается): <0 — a старше b.
func cmpVersion(a, b string) int {
// «Beta 0.5.0» → «0.5.0»: канал перед номером не сравниваем.
if f := strings.Fields(a); len(f) > 0 {
a = f[len(f)-1]
}
if f := strings.Fields(b); len(f) > 0 {
b = f[len(f)-1]
}
pa, pb := strings.Split(strings.SplitN(a, "-", 2)[0], "."), strings.Split(strings.SplitN(b, "-", 2)[0], ".")
for i := 0; i < max(len(pa), len(pb)); i++ {
var x, y int
if i < len(pa) {
x, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
y, _ = strconv.Atoi(pb[i])
}
if x != y {
return x - y
}
}
return 0
}
func closeWS(ws *websocket.Conn, code int, reason string) {
go ws.Close(websocket.StatusCode(code), reason)
}
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
// До апгрейда: без верной подписи ноды (или старого агента в переходном режиме) — 404, как
// у пустого сервера; WebSocket и hello видит только своя нода (hub_preauth.go).
if n.blocked(ip) {
http.NotFound(w, r)
return
}
pre, present := n.preauth(r)
if present && pre == nil {
n.failed(ip)
http.NotFound(w, r)
return
}
if !present && !n.legacyOpen() {
http.NotFound(w, r)
return
}
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
ws.SetReadLimit(hubMaxFrame)
if n.blocked(ip) {
ws.Close(4029, "rate_limited")
return
}
nb := make([]byte, 16)
rand.Read(nb)
nonce := b64url.EncodeToString(nb)
@@ -505,21 +555,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
return
}
row := n.row(int64(m.Get2("node_id").(float64)))
if row == nil {
if row == nil || pre != nil && pre.ID != row.ID {
deny()
return
}
pubB64 := row.Pub.String
joining := !row.Pub.Valid
if joining {
secret, sOK := field(m, "join", "secret").(string)
secret, _ := field(m, "join", "secret").(string)
pub, pOK := field(m, "join", "pub").(string)
if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK {
deny()
return
}
h := sha256.Sum256(fromB64url(secret))
if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 {
if !pOK || !joinOK(row, secret) {
deny()
return
}
@@ -540,6 +585,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
version = v
}
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
// Нода старше MinNodeVersion (или без версии — агент старше 0.3.4) с этой панелью не работает:
// соединение не принимаем, для панели она «нет связи», пока её не обновят (агент переподключится сам).
if v, _ := version.(string); v == "" || cmpVersion(v, MinNodeVersion) < 0 {
logf("nodes: «%s» (#%d) — версия %q, нужна не ниже %s: соединение отклонено", row.Name, row.ID, v, MinNodeVersion)
closeWS(ws, closeOutdated, "outdated "+MinNodeVersion)
return
}
if pre != nil {
n.a.db.Exec("UPDATE nodes SET preauth = 1 WHERE id = ?", row.ID)
}
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
nodeID = row.ID
@@ -560,6 +615,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
if joining {
first = " (первое подключение)"
}
if pre == nil {
first += " — без подписи до апгрейда: старый агент, обновите ноду, чтобы хаб скрылся от сканеров"
}
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
go n.refreshHealth(row.ID)
go n.fireOnline(int(row.ID))
@@ -775,7 +833,8 @@ func (n *Nodes) List() []serverInfo {
}
for _, r := range n.all() {
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]})
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID],
Outdated: r.Pub.Valid && (!r.Version.Valid || cmpVersion(r.Version.String, MinNodeVersion) < 0)})
}
return out
}
@@ -789,11 +848,12 @@ type serverInfo struct {
LastSeen any
Version any
Health *health
Outdated bool // старше MinNodeVersion: хаб её не подключает, пока не обновят
}
func (s serverInfo) json() jsjson.Object {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online},
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}}
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}, {Key: "outdated", Val: s.Outdated}}
}
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
@@ -927,3 +987,15 @@ func (n *Nodes) router() *Router {
})
return rt
}
// randomCN — имя для самоподписанного сертификата: похожее на обычный хост и своё у каждого
// сервера.
func randomCN() string {
const abc = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 10)
rand.Read(b)
for i := range b {
b[i] = abc[int(b[i])%len(abc)]
}
return string(b) + ".net"
}
+88
View File
@@ -0,0 +1,88 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Нода старше MinNodeVersion (или без версии) с панелью не работает: хаб отклоняет её с 4026 и
// не считает подключённой, но версию запоминает — панель показывает, до чего обновлять.
func TestOutdatedNodeRejected(t *testing.T) {
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub)); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
connect := func(version string) (jsjson.Object, websocket.StatusCode) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() (jsjson.Object, error) {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil, err
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o, nil
}
hello, _ := read()
auth := jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(hello.Get2("nonce").(string), 3)))}}
if version != "" {
auth = append(auth, jsjson.KV{Key: "version", Val: version})
}
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(auth))
o, err := read()
return o, websocket.CloseStatus(err)
}
for _, v := range []string{"0.4.9", ""} {
if o, code := connect(v); code != closeOutdated {
t.Fatalf("версия %q: ждали закрытия %d, получили %v / %v", v, closeOutdated, o, code)
}
p.a.nodes.mu.Lock()
_, on := p.a.nodes.conns[3]
p.a.nodes.mu.Unlock()
if on {
t.Fatalf("версия %q: нода считается подключённой", v)
}
}
var stored string
p.a.db.QueryRow("SELECT COALESCE(version, '') FROM nodes WHERE id = 3").Scan(&stored)
if stored != "" { // последняя попытка — без версии
t.Fatalf("версия в БД: %q", stored)
}
if o, code := connect(MinNodeVersion); o.Get2("t") != "ready" {
t.Fatalf("актуальная версия: %v / %v", o, code)
}
}
func TestCmpVersion(t *testing.T) {
for _, c := range []struct {
a, b string
sign int
}{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}, {"Beta 0.4.7", "0.5.0", -1}} {
got := cmpVersion(c.a, c.b)
if (got > 0) != (c.sign > 0) || (got < 0) != (c.sign < 0) {
t.Errorf("cmpVersion(%q, %q) = %d", c.a, c.b, got)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package ui
// Секретный префикс панели (UI_PREFIX): снаружи панель отвечает только на /<префикс>/…, на всё
// остальное — как пустой Go-сервер (`404 page not found`). Порт панели открыт в интернет, и без
// префикса сканер узнавал её по странице логина и JS-бандлу — одним GET.
//
// UI_PREFIX_MOVED — префикс панели, переехавшей сюда (`migrate`): её закладки и внешний API
// приходят через ретранслятор старого сервера со своим путём. Принимаются оба.
//
// С loopback префикс не нужен: CLI, Prometheus этой машины (127.0.0.1:PORT/metrics) и nginx
// на этой же машине ходят по-старому (nginx в подпапке и есть свой секретный путь). Адрес —
// соединения, не X-Forwarded-For: заголовок подделывается.
import (
"net"
"net/http"
"os"
"strings"
"github.com/maeneko/forgetting/internal/migrate"
)
// uiPrefixes — UI_PREFIX и UI_PREFIX_MOVED из окружения; пустые и кривые пропускаются.
func uiPrefixes() []string {
var out []string
for _, k := range []string{"UI_PREFIX", "UI_PREFIX_MOVED"} {
p := strings.Trim(strings.TrimSpace(os.Getenv(k)), "/")
switch {
case p == "":
case !migrate.ValidUIPrefix(p):
warnf("ui: %s «%s» не подходит (8–64 символа A–Z a–z 0–9 _ -) — пропущен", k, p)
default:
out = append(out, p)
}
}
return out
}
func isLoopback(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// prefixGate пропускает в панель только запросы под префиксом и срезает его: дальше роутер
// видит те же пути, что без префикса. allow — исключения вне префикса (приём переноса).
func prefixGate(prefixes []string, next http.Handler, allow func(*http.Request) bool) http.Handler {
if len(prefixes) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
for _, px := range prefixes {
root := "/" + px
if p == root {
// Относительный адрес, как у static: за прокси в подпапке абсолютный увёл бы из неё.
w.Header().Set("Location", px+"/")
w.WriteHeader(http.StatusMovedPermanently)
return
}
if strings.HasPrefix(p, root+"/") {
r2 := r.Clone(r.Context())
r2.URL.Path = p[len(root):]
if r.URL.RawPath != "" {
r2.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, root)
}
r2.RequestURI = r2.URL.RequestURI()
next.ServeHTTP(w, r2)
return
}
}
if isLoopback(r.RemoteAddr) || allow != nil && allow(r) {
next.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
}
+57
View File
@@ -0,0 +1,57 @@
package ui
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPrefixGate(t *testing.T) {
var got string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.URL.Path
w.WriteHeader(200)
})
allow := func(r *http.Request) bool { return r.Method == "POST" && r.URL.Path == "/migrate/v1/import" }
h := prefixGate([]string{"s3cretPath", "0ldPanel_x"}, next, allow)
cases := []struct {
method, remote, path string
code int
seen, location string
}{
{"GET", "203.0.113.5:4000", "/", 404, "", ""},
{"GET", "203.0.113.5:4000", "/ui/brand", 404, "", ""},
{"GET", "203.0.113.5:4000", "/s3cretPathX/", 404, "", ""},
{"GET", "203.0.113.5:4000", "/s3cretPath", 301, "", "s3cretPath/"},
{"GET", "203.0.113.5:4000", "/s3cretPath/", 200, "/", ""},
{"GET", "203.0.113.5:4000", "/s3cretPath/assets/a.js", 200, "/assets/a.js", ""},
{"GET", "203.0.113.5:4000", "/0ldPanel_x/api/v1/users", 200, "/api/v1/users", ""},
{"POST", "203.0.113.5:4000", "/migrate/v1/import", 200, "/migrate/v1/import", ""},
{"GET", "203.0.113.5:4000", "/migrate/v1/import", 404, "", ""},
{"GET", "127.0.0.1:4000", "/metrics", 200, "/metrics", ""},
{"GET", "[::1]:4000", "/", 200, "/", ""},
}
for _, c := range cases {
got = ""
r := httptest.NewRequest(c.method, c.path, nil)
r.RemoteAddr = c.remote
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != c.code || got != c.seen || w.Header().Get("Location") != c.location {
t.Errorf("%s %s от %s: %d, роутер видит %q, Location %q; ждали %d, %q, %q",
c.method, c.path, c.remote, w.Code, got, w.Header().Get("Location"), c.code, c.seen, c.location)
}
if c.code == 404 && w.Body.String() != "404 page not found\n" {
t.Errorf("%s: тело 404 не как у пустого Go-сервера: %q", c.path, w.Body.String())
}
}
got = ""
r := httptest.NewRequest("GET", "/ui/brand", nil)
r.RemoteAddr = "203.0.113.5:4000"
prefixGate(nil, next, nil).ServeHTTP(httptest.NewRecorder(), r)
if got != "/ui/brand" {
t.Errorf("без префикса запрос должен доходить до роутера как есть, дошёл %q", got)
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestRetireReadiness(t *testing.T) {
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}, {Key: "version", Val: MinNodeVersion}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got)
}
+15 -11
View File
@@ -91,6 +91,7 @@ func decodeLikeAxios(raw []byte) any {
type App struct {
port int
prefixes []string // UI_PREFIX, UI_PREFIX_MOVED: секретные пути панели (prefix.go)
uiUser string
uiPass string
jwtSecret []byte
@@ -131,14 +132,15 @@ func New() (*App, error) {
return def
}
a := &App{
port: envNum("PORT"),
uiUser: envOr("UI_USER", "admin"),
uiPass: os.Getenv("UI_PASS"),
brand: pick("BRAND", "Forgetting"),
channel: pick("CHANNEL", "Beta"),
version: pick("VERSION", ""),
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
baseDir: cwd,
port: envNum("PORT"),
prefixes: uiPrefixes(),
uiUser: envOr("UI_USER", "admin"),
uiPass: os.Getenv("UI_PASS"),
brand: pick("BRAND", "Forgetting"),
channel: pick("CHANNEL", "Beta"),
version: pick("VERSION", ""),
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
baseDir: cwd,
// awg-ctrl на loopback: системный прокси сюда не нужен.
httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}},
revoked: map[string]bool{},
@@ -847,8 +849,9 @@ func (a *App) routes() *Router {
app.Post("/login", a.login)
app.Post("/logout", auth, a.logout)
// Единственный роут без авторизации: вордмарк нужен уже на экране логина.
app.Get("/ui/brand", func(c *Ctx) bool {
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
// Экран логина обходится без вордмарка.
app.Get("/ui/brand", auth, func(c *Ctx) bool {
return c.JSON(200, jsjson.Object{{Key: "brand", Val: a.brand}, {Key: "channel", Val: a.channel}, {Key: "version", Val: a.version}})
})
app.Get("/ui/apikeys", auth, a.apiKeysList)
@@ -876,6 +879,7 @@ func (a *App) routes() *Router {
app.Use("/ui/metrics-token", auth)
app.Mount("/ui/metrics-token", a.metrics.router())
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Get("/ui/connlog", auth, a.connlog)
app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router())
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
@@ -949,7 +953,7 @@ func (a *App) Run() error {
if err != nil {
return err
}
srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second}
srv := &http.Server{Handler: prefixGate(a.prefixes, a.routes(), a.migration.awaitsImport), ReadHeaderTimeout: 30 * time.Second}
logf("ui: listening on :%d", a.port)
a.nodes.Start()
a.sub.Start()
+86 -50
View File
@@ -288,6 +288,13 @@ func (s *Sub) initSchema() error {
rekey_requested INTEGER NOT NULL DEFAULT 0,
UNIQUE (master_id, device_id)
);
-- Удалённые устройства: ключ авторизации, чтобы проверить подпись их опроса и ответить
-- 410 только им (stealth). Устройства, удалённые до таблицы, получают тихий 404.
CREATE TABLE IF NOT EXISTS revoked_devices (
id INTEGER PRIMARY KEY,
auth_pub TEXT NOT NULL,
revoked_at INTEGER NOT NULL
);
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
-- дальше не читается (кроме переноса ниже).
CREATE TABLE IF NOT EXISTS master_servers (
@@ -749,6 +756,9 @@ func (s *Sub) forgetDevice(q queryer, dev *deviceRow) []int {
q.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", l.ServerID, l.PeerPub.String)
}
}
// Ключ авторизации удалённого устройства — чтобы на его подписанный опрос ответить 410
// (снять конфиг), а на подобранный id без подписи — тихим 404.
q.Exec("INSERT OR REPLACE INTO revoked_devices (id, auth_pub, revoked_at) VALUES (?, ?, ?)", dev.ID, dev.AuthPub, now())
q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом
ids := []int{}
for _, l := range links {
@@ -903,21 +913,23 @@ func reqPath(r *http.Request) string {
}
// signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога).
func (s *Sub) signFailure(c *Ctx, authPub []byte) string {
// signed — подпись сама по себе верна (провал только по времени или повтору): запрос сделан
// ключом устройства, ему можно отвечать по протоколу. Без неё — тихий 404 (stealth).
func (s *Sub) signFailure(c *Ctx, authPub []byte) (why string, signed bool) {
tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", "))
if _, has := c.R.Header["X-Sen-Ts"]; !has {
tsF = jsToNumber("x")
}
sig := c.R.Header.Get("X-Sen-Sig")
if tsF != float64(int64(tsF)) || sig == "" {
return "no_signature"
return "no_signature", false
}
ts := int64(tsF)
if d := ts - now(); d > tsWindow || d < -tsWindow {
return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d)
}
if !verifyRequest(c.R.Method, reqPath(c.R), ts, c.RawBody, sig, authPub) {
return "bad_signature"
return "bad_signature", false
}
if d := ts - now(); d > tsWindow || d < -tsWindow {
return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d), true
}
s.sigMu.Lock()
defer s.sigMu.Unlock()
@@ -928,10 +940,27 @@ func (s *Sub) signFailure(c *Ctx, authPub []byte) string {
}
}
if _, ok := s.seenSigs[sig]; ok {
return "replay"
return "replay", true
}
s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second)
return ""
return "", true
}
// stealth — ответ тому, кто не доказал знание ни секрета ссылки, ни ключа устройства: как у
// пустого Go-сервера, без подписи и JSON. Иначе сканер одним запросом узнавал бы подписку (а с
// публичным ключом из любой ссылки — и конкретный сервер). Клиенту с верной ссылкой это не мешает.
func stealth(c *Ctx) bool {
http.NotFound(c.W, c.R)
return false
}
// frozenFor — перенос панели идёт: доказавшему клиенту — подписанный 502 (он повторит позже).
func (s *Sub) frozenFor(c *Ctx) bool {
if s.frozen.Load() {
s.sendC(c, 502, subErr("unavailable"))
return true
}
return false
}
func (s *Sub) deviceAuth(c *Ctx) bool {
@@ -942,16 +971,31 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
if valid {
dev = s.deviceByID(s.a.db, int64(f))
}
if !valid {
return stealth(c)
}
// Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и
// не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT.
if dev == nil && valid {
// Только если запрос подписан ключом удалённого устройства (revoked_devices): иначе на
// подобранный id сканер получал бы ответ протокола.
if dev == nil {
var authPub string
if s.a.db.QueryRow("SELECT auth_pub FROM revoked_devices WHERE id = ?", int64(f)).Scan(&authPub) != nil {
return stealth(c)
}
if _, signed := s.signFailure(c, fromB64url(authPub)); !signed {
return stealth(c)
}
return s.sendC(c, 410, subErr("revoked"))
}
why := "no_device"
if dev != nil {
why = s.signFailure(c, fromB64url(dev.AuthPub))
why, signed := s.signFailure(c, fromB64url(dev.AuthPub))
if !signed {
return stealth(c)
}
if why == "" {
if s.frozenFor(c) {
return false
}
c.device = dev
return true
}
@@ -959,11 +1003,7 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
if !s.failLimit.allow(ip) {
return s.sendC(c, 429, subErr("rate_limited"))
}
hdr := c.R.Header.Get("X-Sen-Device")
if !hasHdr {
hdr = "-"
}
warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip)
warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), c.R.Header.Get("X-Sen-Device"), why, ip)
return s.sendC(c, 401, subErr("unauthorized"))
}
@@ -986,10 +1026,6 @@ var errLimit = errors.New("limit")
func (s *Sub) register(c *Ctx) error {
ip := c.remoteIP()
if !s.registerLimit.allow(ip) {
s.sendC(c, 429, subErr("rate_limited"))
return nil
}
b := c.Obj()
strRe := func(k string, re *regexp.Regexp) (string, bool) {
v, ok := b.Get2(k).(string)
@@ -1010,7 +1046,23 @@ func (s *Sub) register(c *Ctx) error {
if !ok5 {
version = ""
}
if !ok1 || !ok2 || !ok3 || !ok4 {
// Доказательство — секрет ссылки: без верного секрета ответа протокола нет (stealth).
var master *masterRow
if ok1 {
master = s.masterBySecret(fromB64url(secretB))
}
if master == nil {
stealth(c)
return nil
}
if s.frozenFor(c) {
return nil
}
if !s.registerLimit.allow(ip) {
s.sendC(c, 429, subErr("rate_limited"))
return nil
}
if !ok2 || !ok3 || !ok4 {
s.sendC(c, 400, subErr("bad_request"))
return nil
}
@@ -1019,7 +1071,7 @@ func (s *Sub) register(c *Ctx) error {
authPub := fromB64url(authPubS)
why := "bad_auth_pub"
if len(authPub) == 32 {
why = s.signFailure(c, authPub)
why, _ = s.signFailure(c, authPub)
}
if why != "" {
warnf("sub: 401 register %s ip=%s", why, ip)
@@ -1027,11 +1079,6 @@ func (s *Sub) register(c *Ctx) error {
return nil
}
master := s.masterBySecret(fromB64url(secretB))
if master == nil {
s.sendC(c, 404, subErr("not_found"))
return nil
}
var serverIDs []int
for _, id := range s.serverIDsOf(s.a.db, master.ID) {
if s.a.nodes.Exists(id) {
@@ -1092,23 +1139,18 @@ func (s *Sub) register(c *Ctx) error {
// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет).
func (s *Sub) peek(c *Ctx) error {
ip := c.remoteIP()
secretB, ok := c.Obj().Get2("sub").(string)
ok = ok && reSubSecret.MatchString(secretB)
var master *masterRow
if ok {
master = s.masterBySecret(fromB64url(secretB))
}
// Секрет не тот — ответа протокола нет (stealth): иначе сканер отличал бы подписку.
if master == nil {
if !s.failLimit.allow(ip) {
s.sendC(c, 429, subErr("rate_limited"))
return nil
}
if ok {
s.sendC(c, 404, subErr("not_found"))
} else {
s.sendC(c, 400, subErr("bad_request"))
}
stealth(c)
return nil
}
if s.frozenFor(c) {
return nil
}
s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)},
@@ -1199,18 +1241,12 @@ func (s *Sub) publicRouter() *Router {
rt := newRouter()
rt.bodyLimit = 4 * 1024
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) {
s.send(w, r, 400, subErr("bad_request"))
}
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
// Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable
// клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже.
rt.Use("/", func(c *Ctx) bool {
if s.frozen.Load() {
return s.sendC(c, 502, subErr("unavailable"))
}
return true
})
// Всё, что не доказало знание секрета ссылки или ключа устройства, получает ответ пустого
// Go-сервера (stealth): битое тело, чужой путь, чужой метод. Подписанные ответы — только
// своим. Перенос панели (frozen) — тоже только им: 502 unavailable клиент понимает как
// «подписка недоступна», туннель не трогает и повторит позже (frozenFor).
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { http.NotFound(w, r) }
rt.notFound = func(c *Ctx) { stealth(c) }
rt.Post("/sub/v1/register", s.wrapSub(s.register))
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
+105
View File
@@ -0,0 +1,105 @@
package ui
import (
"crypto/ed25519"
"crypto/rand"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// Публичный листенер подписки: ответ протокола (подписанный JSON) — только тому, кто знает
// секрет ссылки или ключ устройства. Остальным — 404 пустого Go-сервера.
func TestSubStealth(t *testing.T) {
a := testApp(t, t.TempDir(), "on")
_, signKey, _ := ed25519.GenerateKey(rand.Reader)
s := &Sub{a: a, signKey: signKey, seenSigs: map[string]time.Time{}}
s.registerLimit = newLimiter(30, time.Minute, func(string) {})
s.failLimit = newLimiter(30, time.Minute, func(string) {})
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
devPub, devKey, _ := ed25519.GenerateKey(rand.Reader)
oldPub, oldKey, _ := ed25519.GenerateKey(rand.Reader)
_, strangerKey, _ := ed25519.GenerateKey(rand.Reader)
secret := make([]byte, 16)
rand.Read(secret)
for _, q := range []struct {
sql string
args []any
}{
{"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k', ?, 1)", []any{secret}},
{"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', ?, 1)", []any{b64url.EncodeToString(devPub)}},
{"INSERT INTO revoked_devices (id, auth_pub, revoked_at) VALUES (6, ?, 1)", []any{b64url.EncodeToString(oldPub)}},
} {
if _, err := a.db.Exec(q.sql, q.args...); err != nil {
t.Fatalf("%s: %v", q.sql, err)
}
}
rt := s.publicRouter()
do := func(method, path, body string, hdr map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.RemoteAddr = "203.0.113.5:4000"
if body != "" {
r.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
rt.ServeHTTP(w, r)
return w
}
signed := func(key ed25519.PrivateKey, dev int, method, path string, ts int64) map[string]string {
sig := ed25519.Sign(key, []byte(requestSigString(method, path, ts, nil)))
return map[string]string{"X-Sen-Device": strconv.Itoa(dev), "X-Sen-Ts": strconv.FormatInt(ts, 10),
"X-Sen-Sig": b64url.EncodeToString(sig)}
}
stealthy := func(name string, w *httptest.ResponseRecorder) {
t.Helper()
if w.Code != 404 || w.Body.String() != "404 page not found\n" {
t.Errorf("%s: ждали тихий 404, получили %d %q", name, w.Code, w.Body.String())
}
}
protocol := func(name string, w *httptest.ResponseRecorder, code int) {
t.Helper()
if w.Code != code || !strings.Contains(w.Body.String(), `"sig"`) {
t.Errorf("%s: ждали подписанный %d, получили %d %q", name, code, w.Code, w.Body.String())
}
}
sec := `{"sub":"` + b64url.EncodeToString(secret) + `"}`
other := make([]byte, 16)
rand.Read(other)
now := time.Now().Unix()
stealthy("GET /", do("GET", "/", "", nil))
stealthy("чужой путь", do("GET", "/sub/v1/nope", "", nil))
stealthy("чужой метод", do("GET", "/sub/v1/peek", "", nil))
stealthy("битый JSON", do("POST", "/sub/v1/peek", "{", nil))
stealthy("peek без секрета", do("POST", "/sub/v1/peek", `{"sub":"x"}`, nil))
stealthy("peek чужой секрет", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil))
stealthy("register чужой секрет", do("POST", "/sub/v1/register", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil))
stealthy("config без заголовков", do("GET", "/sub/v1/config", "", nil))
stealthy("config чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now)))
stealthy("config подобранный id", do("GET", "/sub/v1/config", "", signed(strangerKey, 999, "GET", "/sub/v1/config", now)))
stealthy("config удалённого чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 6, "GET", "/sub/v1/config", now)))
protocol("peek своим секретом", do("POST", "/sub/v1/peek", sec, nil), 200)
protocol("register своим секретом, без полей", do("POST", "/sub/v1/register", sec, nil), 400)
protocol("удалённое устройство своей подписью", do("GET", "/sub/v1/config", "", signed(oldKey, 6, "GET", "/sub/v1/config", now)), 410)
protocol("часы устройства уехали", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now-3600)), 401)
s.frozen.Store(true)
stealthy("перенос: чужой", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil))
protocol("перенос: свой", do("POST", "/sub/v1/peek", sec, nil), 502)
protocol("перенос: устройство", do("GET", "/sub/v1/devices", "", signed(devKey, 5, "GET", "/sub/v1/devices", now)), 502)
s.frozen.Store(false)
// Удаление устройства оставляет его ключ в revoked_devices: дальше — 410 только ему.
s.forgetDeviceTx(s.deviceByID(a.db, 5))
protocol("после удаления — своей подписью", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now+1)), 410)
stealthy("после удаления — чужой", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now)))
}
+1
View File
@@ -62,6 +62,7 @@ try {
await call("login broken json", "POST", "/login", undefined, { raw: "{" });
const { token } = await call("login", "POST", "/login", { user: "admin", pass: "benchpass" });
const T = { token };
await call("brand auth", "GET", "/ui/brand", undefined, T);
await call("apikeys no auth", "GET", "/ui/apikeys");
await call("apikeys bad token", "GET", "/ui/apikeys", undefined, { token: "a.b.c" });
+3 -2
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-<arch>.tar.gz.
# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env.
# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env и
# install.sh (им `awg-ctrl uninstall` удаляет установку без сети).
# Фронтенд должен быть собран заранее (npm run build в awg-ui). Общий для GitHub и Gitea.
set -eu
VERSION="$1"
@@ -11,7 +12,7 @@ for ARCH in amd64 arm64; do
CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o "$D/awg-control" ./cmd/awg-control
cp -r awg-ui/dist "$D/awg-ui/dist"
cp .env "$D/"
cp .env install.sh "$D/"
tar -czf "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" "$D/"
rm -rf "$D"
echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz"