Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2ebdcfd0 | ||
|
|
67204ddc25 | ||
|
|
5b29993d5d | ||
|
|
17bd6b19a4 | ||
|
|
f0949c9136 | ||
|
|
0a9eaa8c8e | ||
|
|
ea07914249 | ||
|
|
1169a0adc7 | ||
|
|
ff2d71600c | ||
|
|
1a54d05cca | ||
|
|
fd5e2cc761 | ||
|
|
a3d4664214 | ||
|
|
619af21085 | ||
|
|
13da9a7200 | ||
|
|
aa848de31c | ||
|
|
63e84e0df7 | ||
|
|
c7d1333125 | ||
|
|
aa2a53808a | ||
|
|
eb9fd700cb | ||
|
|
96c21408e6 | ||
|
|
7ee126af30 | ||
|
|
45c96aa0b6 | ||
|
|
eb19cacf93 |
@@ -11,4 +11,4 @@
|
|||||||
|
|
||||||
BRAND=Forgetting
|
BRAND=Forgetting
|
||||||
CHANNEL=Beta
|
CHANNEL=Beta
|
||||||
VERSION=0.4.4
|
VERSION=0.5.0
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
# Forgetting
|
# 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]
|
> [!WARNING]
|
||||||
> Данный проект предназначен только для использования в законных целях.
|
> Данный проект предназначен только для использования в законных целях.
|
||||||
|
|
||||||
|
- **VPN-пользователи** с ключами `vpn://` для AmneziaVPN и **мастер-ключи** `sen://` для SenAWG:
|
||||||
|
одна ссылка на несколько устройств, каждое регистрируется само.
|
||||||
|
- **Несколько серверов** под одной панелью: ноды подключаются к ней сами, панель можно
|
||||||
|
вынести на отдельный сервер без VPN.
|
||||||
|
- **Журнал подключений**: кто, когда и сколько был в VPN, кто давно не подключался.
|
||||||
|
- **Мониторинг**: встроенный Prometheus и графики в панели, метрики для своего Prometheus.
|
||||||
|
- **Защита от обнаружения**: параметры обфускации, порты и пути у каждого сервера свои, а
|
||||||
|
открытые порты отвечают сканеру как пустой веб-сервер.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Требования](#требования)
|
||||||
|
- [Установка](#установка) · [Параметры установщика](#параметры-установщика) · [Обновление и удаление](#обновление-и-удаление)
|
||||||
|
- [Несколько серверов](#несколько-серверов)
|
||||||
|
- [Защита от обнаружения](#защита-от-обнаружения)
|
||||||
|
- [Журнал подключений](#журнал-подключений)
|
||||||
|
- [Мониторинг](#мониторинг)
|
||||||
|
- [Панель за nginx](#панель-за-nginx)
|
||||||
|
- [API](#api)
|
||||||
|
- [Управление](#управление) · [Настройки `cli.env`](#настройки-clienv)
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли;
|
Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли;
|
||||||
@@ -9,17 +32,34 @@
|
|||||||
|
|
||||||
| Компонент | Роль |
|
| Компонент | Роль |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
|
| **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` и своя база его ключей. Единственный сервис, доступный по сети. |
|
| **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. |
|
| **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.
|
- **Права**: root.
|
||||||
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
|
- **Виртуализация**: KVM или железо (не OpenVZ/LXC/Docker/WSL — нужен загружаемый
|
||||||
|
kernel-модуль AmneziaWG).
|
||||||
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
|
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
|
||||||
- **Архитектура**: x86_64 или aarch64.
|
- **Архитектура**: x86_64 или aarch64.
|
||||||
|
|
||||||
@@ -33,37 +73,225 @@
|
|||||||
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
|
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
|
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
|
||||||
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
|
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор с текстом скрипта.
|
||||||
на 2.0 можно так:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
|
sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
|
Установщик спросит роль сервера:
|
||||||
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
|
|
||||||
автоматически.
|
|
||||||
|
|
||||||
Источник архива и имя панели не зашиты в код — задаются переменными
|
| Роль | Что ставится |
|
||||||
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
|
|---|---|
|
||||||
(подробности — в комментариях `install.sh` и в `.env`).
|
| **Панель + VPN** (`standalone`) | Всё на одной машине; к ней можно подключать ноды |
|
||||||
|
| **Только панель** (`core`) | Панель без VPN; серверы подключаются к ней нодами |
|
||||||
|
| **Нода** (`node`) | VPN без панели; подключается к панели по строке `awgjoin://…` («Добавить сервер» в панели) |
|
||||||
|
|
||||||
|
В конце установщик печатает адрес панели **вместе с секретным путём** —
|
||||||
|
`http://IP:PORT/<путь>/` — логин и пароль. Без пути панель отвечает `404`, поэтому адрес
|
||||||
|
сохраните целиком; позже его покажут `awg-ctrl status` и `awg-ctrl credentials`.
|
||||||
|
|
||||||
|
Порты на самом сервере установщик открывает сам (UFW, firewalld или iptables — что
|
||||||
|
активно). **Фаервол хостера** (панель провайдера, security group) отсюда не виден: открой там
|
||||||
|
порты из итога установки, иначе клиенты не подключатся, а на сервере всё будет выглядеть
|
||||||
|
исправным.
|
||||||
|
|
||||||
|
Сервер ставится на AmneziaWG 3.1. Если модуль в ядре старше 3.x или ядро старше 5.5,
|
||||||
|
установка остановится с диагностикой — поставить на 2.0 можно через `AWG_GEN=auto`. Перевести
|
||||||
|
сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели: пользователи сохраняются,
|
||||||
|
ключи перевыпускаются автоматически.
|
||||||
|
|
||||||
|
### Параметры установщика
|
||||||
|
|
||||||
|
Задаются переменными окружения перед запуском, например
|
||||||
|
`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/; }
|
||||||
|
location ^~ /forgetting/ {
|
||||||
|
proxy_pass http://127.0.0.1:39708/; # слэш в конце срезает префикс
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
nginx на этой же машине ходит к панели по loopback, поэтому секретный путь ему не нужен —
|
||||||
|
подпапка в nginx и становится путём панели (выберите неочевидную). Адрес клиента панель берёт
|
||||||
|
из `X-Forwarded-For` / `X-Real-IP` только от доверенного прокси: с loopback — всегда, с других
|
||||||
|
адресов — если они перечислены в `TRUST_PROXY`. Без этого все входы шли бы с адреса прокси и
|
||||||
|
делили один лимит неудачных попыток.
|
||||||
|
|
||||||
|
Сам порт панели после этого можно закрыть снаружи (`ufw deny <UI_PORT>/tcp`). Порты подписки
|
||||||
|
(`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты и ноды ходят на них
|
||||||
|
напрямую, не через nginx.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
|
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — стабильный контракт
|
||||||
стабильный контракт для сторонних программ, авторизация по API-ключу.
|
для сторонних программ, авторизация по API-ключу. **Роуты панели** — всё, что умеет
|
||||||
**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
|
веб-интерфейс, авторизация по JWT.
|
||||||
URL в обоих случаях — адрес сервера и порт, выбранный при установке.
|
|
||||||
|
В примерах `HOST:PORT` — адрес панели **вместе с секретным путём**:
|
||||||
|
`http://203.0.113.9:39708/k3f9xQ2mTq8vLw0a`. Без пути снаружи любой запрос получает `404`.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
|
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный контракт,
|
||||||
> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет
|
> в отличие от `/api/v1`. По голому HTTP они идут открытым текстом: ставьте панель за TLS.
|
||||||
> без TLS и ограничения по адресам.
|
|
||||||
|
|
||||||
### Публичный API (`/api/v1`)
|
### Публичный API (`/api/v1`)
|
||||||
|
|
||||||
Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`.
|
Ключ создаётся в «Настройках», на вкладке «API-ключи», и уходит в заголовке `X-Api-Key`.
|
||||||
Открытое значение показывается один раз при создании — на сервере лежит только
|
Открытое значение показывается один раз при создании — на сервере лежит только
|
||||||
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
|
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
|
||||||
`psk_key` и `pub_key` не покидают сервер.
|
`psk_key` и `pub_key` не покидают сервер.
|
||||||
@@ -81,22 +309,20 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
|
|||||||
| `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден |
|
| `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден |
|
||||||
| `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` |
|
| `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` |
|
||||||
|
|
||||||
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или
|
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или `"3.1"`). Операции
|
||||||
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
|
только CRD: у пира нет изменяемых полей, «перевыпустить» ключ = удалить и создать заново.
|
||||||
ключ = удалить и создать заново.
|
|
||||||
|
|
||||||
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка;
|
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; `0` — сервер
|
||||||
`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://`
|
самой панели, остальные — подключённые ноды). Пользователи `vpn://` заводятся на сервере ключа
|
||||||
заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается
|
по умолчанию; другой сервер из набора выбирается заголовком `X-Server-Id: <id>`, сервер вне
|
||||||
заголовком `X-Server-Id: <id>`, сервер вне набора → `404`.
|
набора → `404`.
|
||||||
|
|
||||||
#### Мастер-ключи `sen://`
|
#### Мастер-ключи `sen://`
|
||||||
|
|
||||||
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое
|
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое устройство
|
||||||
устройство само регистрируется по ссылке и получает пира на всех серверах ключа,
|
само регистрируется по ссылке и получает пира на всех серверах ключа, его приватный ключ сервер
|
||||||
его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только
|
не видит. API-ключ видит и меняет мастер-ключ, только если **все** его серверы входят в набор
|
||||||
если **все** его серверы входят в набор API-ключа; остальные для него не
|
API-ключа; остальные для него не существуют (`404`).
|
||||||
существуют (`404`).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
|
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
|
||||||
@@ -124,12 +350,11 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
|
|||||||
| `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен |
|
| `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен |
|
||||||
| `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает |
|
| `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает |
|
||||||
|
|
||||||
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
|
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`), поэтому переустановка их
|
||||||
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
|
не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей
|
||||||
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
|
внутренней авторизацией. Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys`
|
||||||
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем
|
(с полем `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
|
||||||
`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
|
перевыпуская ключ, и `DELETE /ui/apikeys/:id`.
|
||||||
перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`.
|
|
||||||
|
|
||||||
### Авторизация панели
|
### Авторизация панели
|
||||||
|
|
||||||
@@ -144,19 +369,20 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
|||||||
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
|
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
|
||||||
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
|
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
|
||||||
|
|
||||||
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
|
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`; сервер — заголовком `X-Server-Id`
|
||||||
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
|
(без него — сервер по умолчанию). Ниже — роуты, которые дёргает сама панель; в отличие от
|
||||||
|
`/api/v1` их форма может меняться.
|
||||||
|
|
||||||
### Состояние
|
### Состояние
|
||||||
|
|
||||||
| Метод | Ответ |
|
| Метод | Ответ |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.5.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||||
|
|
||||||
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс:
|
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: `"2"` или
|
||||||
`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
|
`"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
|
||||||
|
|
||||||
### Пользователи
|
### Пользователи
|
||||||
|
|
||||||
@@ -176,8 +402,16 @@ curl -s -X POST http://HOST:PORT/api/users \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
|
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
|
||||||
base64url-декодировать, отбросить первые 4 байта (длина), распаковать
|
base64url-декодировать, отбросить первые 4 байта (длина), распаковать `zlib inflate` → JSON;
|
||||||
`zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
|
текст конфига лежит в `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
|
### Интерфейс AWG
|
||||||
|
|
||||||
@@ -187,10 +421,9 @@ base64url-декодировать, отбросить первые 4 байта
|
|||||||
| `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся |
|
| `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` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` |
|
||||||
|
|
||||||
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не
|
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не 3.x или ядро
|
||||||
3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло
|
старше 5.5, и `500` с текстом причины, если ядро не приняло 3.1-конфиг — в этом случае прежний
|
||||||
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
|
конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0.
|
||||||
поднимается обратно на 2.0.
|
|
||||||
|
|
||||||
### Мастер-ключи
|
### Мастер-ключи
|
||||||
|
|
||||||
@@ -198,8 +431,9 @@ base64url-декодировать, отбросить первые 4 байта
|
|||||||
|
|
||||||
| Метод | Что делает |
|
| Метод | Что делает |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }], defaults }`; `:id` в путях — id или `uuid`; `defaults` — серверы новых ключей или `null` |
|
||||||
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
|
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201`; без `servers` — `defaults`, а нет их — сервер по умолчанию |
|
||||||
|
| `POST /ui/masterkeys/servers` | «Серверы для всех»: `{ add?, remove?, defaults?, dry_run? }` — `add` появляются у всех живых ключей, `remove` снимаются у всех, прочие серверы ключей не трогаются; `defaults` (`null` — сбросить) — набор новых ключей. → `{ changed, total, hidden: [{ id, label, keys }] }`, `hidden` — ключи API, которые перестанут видеть столько мастер-ключей (сервер вне их области). `409`, если у ключа не останется серверов |
|
||||||
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
|
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
|
||||||
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
|
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
|
||||||
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
|
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
|
||||||
@@ -208,17 +442,66 @@ base64url-декодировать, отбросить первые 4 байта
|
|||||||
| `DELETE /ui/devices/:id` | Отвязать устройство |
|
| `DELETE /ui/devices/:id` | Отвязать устройство |
|
||||||
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
|
| `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
|
```bash
|
||||||
awg-ctrl
|
awg-ctrl # меню
|
||||||
|
awg-ctrl start|stop|restart|status # status показывает и адрес панели с путём
|
||||||
|
awg-ctrl credentials [user|pass] # логин и пароль панели, адрес
|
||||||
|
awg-ctrl join '<awgjoin://…>' [--no-panel] / unjoin
|
||||||
|
awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес
|
||||||
|
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 (автозапуск на загрузке):
|
Через systemd (автозапуск на загрузке):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl start|stop|restart awg-control
|
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
|
[MIT](LICENSE) © 2026 Ivan Vasilev
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-agent",
|
"name": "awg-agent",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AWG Panel</title>
|
<title>Вход</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+187
-30
@@ -15,7 +15,7 @@ const now = () => Math.floor(Date.now() / 1000);
|
|||||||
const rnd = (n: number) => Math.floor(Math.random() * n);
|
const rnd = (n: number) => Math.floor(Math.random() * n);
|
||||||
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
||||||
const GEN = "3.1";
|
const GEN = "3.1";
|
||||||
const MOCK_VERSION = "0.3.3";
|
const MOCK_VERSION = "0.5.0";
|
||||||
|
|
||||||
// ── пользователи (vpn://) ──
|
// ── пользователи (vpn://) ──
|
||||||
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
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)];
|
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 }
|
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
|
||||||
const mnodes: MNode[] = [
|
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: 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.1" }, // новее панели — жёлтый чип
|
||||||
];
|
];
|
||||||
let nodeId = 3;
|
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");
|
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,7 +54,9 @@ 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[] }
|
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;
|
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 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] }];
|
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 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 } : {}) });
|
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 } : {}) });
|
||||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||||
@@ -73,9 +76,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 = () => [
|
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" } },
|
{ 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,
|
...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 })),
|
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() }));
|
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
|
||||||
@@ -104,23 +115,91 @@ 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 с."],
|
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 удалён вместе с накопленными графиками."],
|
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 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 MON_MS = 6000;
|
||||||
const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS;
|
const monRunning = (j?: MonJob) => !!j && j.exit === undefined && Date.now() - j.start < MON_MS;
|
||||||
app.get("/ui/monitor", (_q, r) => {
|
const monTarget = (q: express.Request) => monReady || !MOCK_CORE ? monHost : Number(q.query.server ?? 0);
|
||||||
if (monJob && !monRunning()) monReady = monJob.action === "install";
|
const monCan = (id: number) => { const n = mnodes.find(x => x.id === id); return !n || (n.online && n.version !== null); };
|
||||||
let job = null;
|
app.get("/ui/monitor", (q, r) => {
|
||||||
if (monJob) {
|
const target = monTarget(q), can = monCan(target), mj = monJobs[target];
|
||||||
const lines = MON_LOGS[monJob.action];
|
if (mj && mj.exit === undefined && !monRunning(mj)) { // операция дошла до конца
|
||||||
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length;
|
mj.exit = 0;
|
||||||
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") };
|
monReady = mj.action === "install";
|
||||||
|
if (monReady) monHost = target;
|
||||||
}
|
}
|
||||||
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", 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) {
|
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 с — «перезапуск»);
|
||||||
|
// MOCK_ROLE=retire — панель уже приняла перенос: готовность к отключению старого сервера.
|
||||||
|
const MOCK_CORE = process.env.MOCK_ROLE === "core" || process.env.MOCK_ROLE === "retire";
|
||||||
|
let mv: { state: string; error: string | null; target: string | null; code: { code: string; at: number } | null; movedAt: number } =
|
||||||
|
{ state: "", error: null, target: null, code: null, movedAt: process.env.MOCK_ROLE === "retire" ? Date.now() : 0 };
|
||||||
|
const mvRetire = () => {
|
||||||
|
if (!mv.movedAt) return null;
|
||||||
|
const t = (Date.now() - mv.movedAt) / 1000; // клиенты и ноды «узнают» адрес со временем
|
||||||
|
return { since: Math.floor(mv.movedAt / 1000), home: "198.51.100.20:40500",
|
||||||
|
old: { exists: true, online: true },
|
||||||
|
devices: { total: 12, fresh: Math.min(12, 7 + Math.floor(t / 15)) }, unused_links: 1,
|
||||||
|
nodes: [{ id: 1, name: "Frankfurt", online: true, ok: true }, { id: 2, name: "Tokyo", online: false, ok: false },
|
||||||
|
{ id: 4, name: "Amsterdam", online: true, ok: t > 20 }] };
|
||||||
|
};
|
||||||
|
const mvView = () => {
|
||||||
|
if (mv.code && Date.now() - mv.code.at > 10000) mv = { ...mv, state: "imported", code: null, movedAt: Date.now() };
|
||||||
|
if (mv.state === "imported" && Date.now() - mv.movedAt > 10000) mv = { ...mv, state: "" };
|
||||||
|
return { state: mv.state, error: mv.error, target: mv.target,
|
||||||
|
code: mv.code && { code: mv.code.code, expires_in: 3600 - Math.floor((Date.now() - mv.code.at) / 1000) },
|
||||||
|
...(mv.state === "imported" ? {} : {
|
||||||
|
export: MOCK_CORE ? "У этой панели нет своего VPN: переносить её как сервер-ноду нечего" : null,
|
||||||
|
accept: !MOCK_CORE ? "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
|
||||||
|
: mv.movedAt ? "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую" : null,
|
||||||
|
retire: mvRetire() }) };
|
||||||
|
};
|
||||||
|
app.get("/ui/migrate", (_q, r) => r.json(mvView()));
|
||||||
|
app.post("/ui/migrate/code", (q, r) => {
|
||||||
|
if (!MOCK_CORE) return r.status(409).json({ error: "У этой панели свой VPN" });
|
||||||
|
mv.code = { code: "awgmove://" + Buffer.from(JSON.stringify({ v: 1, h: q.body?.host ?? "127.0.0.1", p: 8080, k: "mock" })).toString("base64url"), at: Date.now() };
|
||||||
|
r.json(mvView());
|
||||||
|
});
|
||||||
|
app.delete("/ui/migrate/code", (_q, r) => { mv.code = null; r.json(mvView()); });
|
||||||
|
app.post("/ui/migrate/export", (q, r) => {
|
||||||
|
const code = String(q.body?.code ?? "");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!code.startsWith("awgmove://ey")) return r.status(400).json({ error: "код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели" });
|
||||||
|
mv = { ...mv, state: "moved", target: "http://198.51.100.20:31337" };
|
||||||
|
r.json({ ok: true, target: mv.target });
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
app.post("/ui/migrate/cancel", (_q, r) => { mv = { ...mv, state: "", error: null }; r.json(mvView()); });
|
||||||
|
app.delete("/ui/migrate/retire", (_q, r) => { mv.movedAt = 0; r.json(mvView()); });
|
||||||
|
|
||||||
app.get("/ui/monitor/overview", (q, r) => {
|
app.get("/ui/monitor/overview", (q, r) => {
|
||||||
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
|
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
|
||||||
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
|
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
|
||||||
@@ -136,19 +215,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 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 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]);
|
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; // бит/с × с → байты
|
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: [] } });
|
// Ряды одного сервера: ядро (0) — основная нагрузка, нода Frankfurt (1) — поменьше
|
||||||
r.json({ range: q.query.range ?? "1h", step, from, to,
|
const one = (id: number) => {
|
||||||
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,
|
const k = id === 0 ? 0.7 : 0.3;
|
||||||
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
|
const d = scale(down, k), u = scale(up, k);
|
||||||
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,
|
return {
|
||||||
used_down: used(down1), used_up: used(up1) }],
|
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,
|
||||||
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
|
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
|
||||||
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } });
|
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 (без проверки токена — мок).
|
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
|
||||||
@@ -164,7 +262,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'));
|
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
|
||||||
|
|
||||||
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
|
// Деление канала: состояние по серверам; 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 shapingView = (id: number) => {
|
||||||
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
|
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
|
||||||
const on = settings.enabled;
|
const on = settings.enabled;
|
||||||
@@ -207,6 +307,49 @@ app.put("/api/torrent", (q, r) => {
|
|||||||
r.json(torrentView(id));
|
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) }));
|
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
||||||
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
|
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
|
||||||
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
|
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
|
||||||
@@ -248,11 +391,25 @@ app.post("/ui/masterkeys/audit/clean", (_q, r) => {
|
|||||||
mockGhost = false; r.json(mockAudit());
|
mockGhost = false; r.json(mockAudit());
|
||||||
});
|
});
|
||||||
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
|
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
|
||||||
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
|
let mkDefaults: number[] | null = null;
|
||||||
|
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut), defaults: mkDefaults }));
|
||||||
|
// «Серверы для всех» (sub_admin.go bulkServers): add — у всех, remove — ни у кого.
|
||||||
|
app.post("/ui/masterkeys/servers", (q, r) => {
|
||||||
|
const add: number[] = q.body.add ?? [], remove: number[] = q.body.remove ?? [];
|
||||||
|
if (add.some(x => remove.includes(x))) return r.status(400).json({ error: "Сервер не может быть и у всех, и ни у кого" });
|
||||||
|
const next = masters.map(m => ({ m, s: [...new Set([...m.servers.filter(x => !remove.includes(x)), ...add])].sort() }));
|
||||||
|
const empty = next.filter(x => !x.s.length).length;
|
||||||
|
if (empty) return r.status(409).json({ error: `У ${empty} ключей не останется ни одного сервера — оставьте им хотя бы один` });
|
||||||
|
const changed = next.filter(x => x.s.join() !== [...x.m.servers].sort().join());
|
||||||
|
const within = (l: number[], sc: number[]) => l.every(x => sc.includes(x));
|
||||||
|
const hidden = apiKeys.map(k => ({ id: k.id, label: k.label, keys: changed.filter(x => within(x.m.servers, k.servers) && !within(x.s, k.servers)).length })).filter(h => h.keys);
|
||||||
|
if (!q.body.dry_run) { changed.forEach(x => { x.m.servers = x.s; }); if ("defaults" in q.body) mkDefaults = q.body.defaults; }
|
||||||
|
r.json({ changed: changed.length, total: masters.length, hidden });
|
||||||
|
});
|
||||||
app.post("/ui/masterkeys", (q, r) => {
|
app.post("/ui/masterkeys", (q, r) => {
|
||||||
const limit = q.body.device_limit ?? 3;
|
const limit = q.body.device_limit ?? 3;
|
||||||
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
||||||
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? mkDefaults ?? [0] };
|
||||||
masters.push(m); r.status(201).json(mkOut(m));
|
masters.push(m); r.status(201).json(mkOut(m));
|
||||||
});
|
});
|
||||||
app.patch("/ui/masterkeys/:id", (q, r) => {
|
app.patch("/ui/masterkeys/:id", (q, r) => {
|
||||||
|
|||||||
+2
-1
@@ -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");
|
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
|
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
|
||||||
|
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
|
||||||
const r = spawnSync("openssl", [
|
const r = spawnSync("openssl", [
|
||||||
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
|
"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" });
|
], { stdio: "ignore" });
|
||||||
if (r.status !== 0) throw new Error("openssl");
|
if (r.status !== 0) throw new Error("openssl");
|
||||||
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
|
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+3
-3
@@ -206,9 +206,9 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
|
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
|
||||||
// до выдачи JWT. Наружу уходит только имя продукта и версия.
|
// Экран логина обходится без вордмарка.
|
||||||
app.get("/ui/brand", (_req: Request, res: Response) => {
|
app.get("/ui/brand", requireAuth, (_req: Request, res: Response) => {
|
||||||
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
|
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+511
-265
File diff suppressed because it is too large
Load Diff
+192
-72
@@ -3,15 +3,17 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import './tokens.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import {
|
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,
|
type NodeInfo, type Brand,
|
||||||
} from './lib/shared';
|
} 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 ServerBar from './components/ServerBar';
|
||||||
|
import DrumTitle from './components/DrumTitle';
|
||||||
|
import { useTheme, setThemeMode } from './lib/theme';
|
||||||
import AddServerModal from './components/AddServerModal';
|
import AddServerModal from './components/AddServerModal';
|
||||||
import ServerSettingsModal from './components/ServerSettingsModal';
|
|
||||||
import { TABS, TABS_BY_GROUP } from './tabs';
|
import { TABS, TABS_BY_GROUP } from './tabs';
|
||||||
|
|
||||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||||
@@ -27,14 +29,17 @@ export default function App() {
|
|||||||
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
||||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||||
const [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [serversOpen, setServersOpen] = useState(false); // выпадающий список серверов
|
||||||
|
const serverSelectRef = useRef<HTMLDivElement>(null);
|
||||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
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 [restarting, setRestarting] = useState(false);
|
||||||
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
|
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
const { theme } = useTheme();
|
||||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
|
||||||
const touchStartX = useRef(0);
|
const touchStartX = useRef(0);
|
||||||
const touchCurrentX = useRef(0);
|
const touchCurrentX = useRef(0);
|
||||||
|
|
||||||
@@ -185,23 +190,20 @@ export default function App() {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [token, loadNodes]);
|
}, [token, loadNodes]);
|
||||||
|
|
||||||
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
|
// Имя продукта и версия — с сервера, только после логина: роут за авторизацией, чтобы
|
||||||
// Если не ответил, остаётся фолбэк из констант.
|
// панель не опознавалась сканером. Если не ответил, остаётся фолбэк из констант.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get('/ui/brand')
|
if (!token) return;
|
||||||
|
axios.get('/ui/brand', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
.then(({ data }) => setBrand({
|
.then(({ data }) => setBrand({
|
||||||
brand: data.brand || BRAND_FALLBACK,
|
brand: data.brand || BRAND_FALLBACK,
|
||||||
channel: data.channel || '',
|
channel: data.channel || '',
|
||||||
version: data.version || '',
|
version: data.version || '',
|
||||||
}))
|
}))
|
||||||
.catch(() => { /* остаётся фолбэк */ });
|
.catch(() => { /* остаётся фолбэк */ });
|
||||||
}, []);
|
}, [token]);
|
||||||
|
|
||||||
// Применять и сохранять тему
|
useEffect(() => { document.title = token ? brand.brand : 'Вход'; }, [token, brand.brand]);
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
localStorage.setItem(THEME_KEY, theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
// Блокировать прокрутку фона когда drawer открыт
|
// Блокировать прокрутку фона когда drawer открыт
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,6 +211,72 @@ export default function App() {
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [drawerOpen]);
|
}, [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
|
// Закрывать drawer по Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!drawerOpen) return;
|
if (!drawerOpen) return;
|
||||||
@@ -223,33 +291,27 @@ export default function App() {
|
|||||||
const chrome = tab.chrome ?? {};
|
const chrome = tab.chrome ?? {};
|
||||||
const showServerBar = chrome.serverBar !== false;
|
const showServerBar = chrome.serverBar !== false;
|
||||||
const showRestart = chrome.restart !== 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 (
|
return (
|
||||||
<div className="app">
|
<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 ? (
|
{!token ? (
|
||||||
<div className="login-screen">
|
<div className="login-screen">
|
||||||
|
<span className="theme-toggle--fixed">{themeToggle}</span>
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<span className="logo-name">{brand.brand}</span>
|
<span className="logo-name">Вход</span>
|
||||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||||
<input
|
<input
|
||||||
className="field"
|
className="field"
|
||||||
@@ -285,7 +347,8 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<IcoMenu />
|
<IcoMenu />
|
||||||
</button>
|
</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>
|
</header>
|
||||||
|
|
||||||
{/* Затемнение под drawer */}
|
{/* Затемнение под drawer */}
|
||||||
@@ -301,6 +364,15 @@ export default function App() {
|
|||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
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">
|
<div className="sidebar-logo">
|
||||||
<span className="logo-name">{brand.brand}</span>
|
<span className="logo-name">{brand.brand}</span>
|
||||||
<span className="sidebar-version">
|
<span className="sidebar-version">
|
||||||
@@ -308,48 +380,105 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<hr className="drawer-divider" />
|
<hr className="drawer-divider" />
|
||||||
{TABS_BY_GROUP.map(g => (
|
{navGroups.map(g => (
|
||||||
<nav key={g.id} className="nav-group" aria-label={g.label}>
|
<nav key={g.id} className="nav-group" aria-label={g.label}>
|
||||||
<span className="nav-group-title">{g.label}</span>
|
<span className="nav-group-title">{g.label}</span>
|
||||||
{g.tabs.map(t => (
|
{g.tabs.map(t => (
|
||||||
<div
|
<button
|
||||||
key={t.id}
|
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); }}
|
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||||
>
|
>
|
||||||
<t.Icon /> {t.label}
|
<span className="nav-icon"><t.Icon /></span>
|
||||||
</div>
|
<span className="nav-label">{t.label}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="sidebar-spacer" />
|
<div className="sidebar-spacer" />
|
||||||
|
|
||||||
<button className="btn btn--danger" onClick={logout}>
|
<button className="btn btn--danger sidebar-logout" aria-label="Выйти" onClick={logout}>
|
||||||
<IcoLogout /> Выйти
|
<IcoLogout /> <span className="nav-label">Выйти</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="main">
|
<main className="main">
|
||||||
<h2 className="page-title">{tab.label}</h2>
|
{/* Шапка страницы: заголовок слева; справа — выбор сервера, перезапуск AWG и тема.
|
||||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
На телефоне заголовок и тема — в верхнем баре, выбор сервера — во всю ширину. */}
|
||||||
{showServerBar && (
|
<div className="page-head">
|
||||||
<ServerBar
|
<h2 className="page-title"><DrumTitle text={tab.label} order={tabOrder} /></h2>
|
||||||
nodes={nodes}
|
<div className="page-actions">
|
||||||
serverId={serverId}
|
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают.
|
||||||
hubEnabled={hubEnabled}
|
Текущий сервер — кнопкой, остальные — выпадающим списком под ней. */}
|
||||||
panelVersion={panelVersion}
|
{showServerBar && (
|
||||||
onSelect={pickServer}
|
<div className="server-select" ref={serverSelectRef}>
|
||||||
onAdd={() => setModal({})}
|
<button
|
||||||
onJoin={n => setModal({ node: n })}
|
type="button"
|
||||||
onDelete={deleteNode}
|
className={`server-current${serversOpen ? ' server-current--open' : ''}`}
|
||||||
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
|
aria-expanded={serversOpen}
|
||||||
onSettings={n => { pickServer(n.id); setSettingsFor(n); }}
|
aria-controls="server-menu"
|
||||||
onRestartAwg={restartAwg}
|
onClick={() => setServersOpen(o => !o)}
|
||||||
restarting={restarting}
|
>
|
||||||
showRestart={showRestart}
|
<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 ? (
|
{serverId === null && showServerBar ? (
|
||||||
<p className="login-sub">
|
<p className="login-sub">
|
||||||
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
|
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
|
||||||
@@ -373,15 +502,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settingsFor && (
|
|
||||||
<ServerSettingsModal
|
|
||||||
token={token}
|
|
||||||
node={settingsFor}
|
|
||||||
onClose={() => setSettingsFor(null)}
|
|
||||||
showMsg={showMsg}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && <div className="snack">{msg}</div>}
|
{msg && <div className="snack">{msg}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
|
|||||||
{error && <p className="login-error">{error}</p>}
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
|
||||||
<div className="node-modal-actions">
|
<div className="node-modal-actions">
|
||||||
|
<button className="btn btn--text" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
|
||||||
{result ? (
|
{result ? (
|
||||||
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
|
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
|
||||||
<IcoCopy /> Скопировать
|
<IcoCopy /> Скопировать
|
||||||
@@ -94,7 +95,6 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
|
|||||||
Создать
|
Создать
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
// Copyright (c) 2026 Ivan Vasilev
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// 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';
|
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
|
||||||
|
|
||||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
||||||
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
|
// Версия Forgetting на сервере, три состояния: совпадает с панелью — нейтральный чип; нода отстаёт
|
||||||
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
|
// от панели (но не старше MIN_NODE_VERSION) или новее неё — жёлтый, стоит обновить; старше
|
||||||
|
// MIN_NODE_VERSION или не сообщает версию (агент старше 0.3.4) — красный: хаб такую ноду не подключает.
|
||||||
function versionChip(n: NodeInfo, panel: string | null) {
|
function versionChip(n: NodeInfo, panel: string | null) {
|
||||||
if (n.pending) return null;
|
if (n.pending) return null;
|
||||||
let tip = '', warn = false;
|
let tip = '', level: '' | 'warn' | 'bad' = '';
|
||||||
if (!n.version) {
|
if (!n.version) {
|
||||||
warn = true;
|
level = 'bad';
|
||||||
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
|
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) {
|
} else if (panel && !n.local) {
|
||||||
const d = cmpVersion(n.version, panel);
|
const d = cmpVersion(n.version, panel);
|
||||||
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
|
if (d < 0) { level = 'warn'; tip = `Устарела: панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
|
||||||
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
|
else if (d > 0) { level = 'warn'; tip = `Нода новее панели (${panel}) — обнови панель`; }
|
||||||
}
|
}
|
||||||
const chip = (
|
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?.?.?'}
|
{n.version ? `v${n.version}` : 'v?.?.?'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -60,18 +64,17 @@ export default function ServerBar({
|
|||||||
onClick={() => onSelect(n.id)}
|
onClick={() => onSelect(n.id)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
|
||||||
>
|
>
|
||||||
{/* toggle — виден на мобильном вместо чипа */}
|
<span
|
||||||
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
|
className={`status-dot${n.online ? ' status-dot--online' : ''}`}
|
||||||
<div className="server-toggle-thumb" />
|
role="img"
|
||||||
</div>
|
aria-label={n.online ? 'на связи' : 'нет связи'}
|
||||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
/>
|
||||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
|
|
||||||
<span className="chip-dot" />
|
|
||||||
</span>
|
|
||||||
<div className="server-card-info">
|
<div className="server-card-info">
|
||||||
<span className="server-card-name">{h?.server || n.name}</span>
|
<span className="server-card-name">{h?.server || n.name}</span>
|
||||||
{n.pending ? (
|
{n.pending ? (
|
||||||
<span className="server-card-ip">ждёт подключения</span>
|
<span className="server-card-ip">ждёт подключения</span>
|
||||||
|
) : n.outdated ? (
|
||||||
|
<span className="server-card-ip server-card-ip--bad">нужно обновить до {MIN_NODE_VERSION}</span>
|
||||||
) : !n.online ? (
|
) : !n.online ? (
|
||||||
<span className="server-card-ip">нет связи</span>
|
<span className="server-card-ip">нет связи</span>
|
||||||
) : h && (
|
) : h && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ export const IcoMenu = () => (
|
|||||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
</svg>
|
</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 = () => (
|
export const IcoUsers = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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"/>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
@@ -102,9 +107,43 @@ export const IcoPulse = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IcoMove = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
|
||||||
|
</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> = {
|
export const ICONS: Record<string, () => JSX.Element> = {
|
||||||
|
settings: IcoGear,
|
||||||
users: IcoUsers,
|
users: IcoUsers,
|
||||||
key: IcoKey,
|
key: IcoKey,
|
||||||
shield: IcoShield,
|
shield: IcoShield,
|
||||||
pulse: IcoPulse,
|
pulse: IcoPulse,
|
||||||
|
move: IcoMove,
|
||||||
|
history: IcoHistory,
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -56,6 +56,19 @@ export interface Brand { brand: string; channel: string; version: string }
|
|||||||
export const TOKEN_KEY = 'awg_token';
|
export const TOKEN_KEY = 'awg_token';
|
||||||
export const THEME_KEY = 'awg_theme';
|
export const THEME_KEY = 'awg_theme';
|
||||||
export const SERVER_KEY = 'awg_server';
|
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.
|
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
@@ -66,6 +79,7 @@ export interface NodeInfo {
|
|||||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
||||||
last_seen: number | null;
|
last_seen: number | null;
|
||||||
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
|
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;
|
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;
|
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.
|
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||||
export function cmpVersion(a: string, b: string): number {
|
export function cmpVersion(a: string, b: string): number {
|
||||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -3,8 +3,17 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import axios from 'axios';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
// Пути к API в коде пишутся от корня («/ui/nodes»), а уходят от адреса страницы («ui/nodes»):
|
||||||
|
// так панель за reverse-proxy в подпапке (example.com/forgetting/) ходит в свой префикс, а в корне
|
||||||
|
// всё как раньше. Одно место на все запросы, включая будущие.
|
||||||
|
axios.interceptors.request.use(c => {
|
||||||
|
if (c.url?.startsWith('/') && !c.url.startsWith('//')) c.url = c.url.slice(1);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "apikeys",
|
|
||||||
"label": "API-ключи",
|
|
||||||
"icon": "key",
|
|
||||||
"group": "main",
|
|
||||||
"order": 2
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "audit",
|
|
||||||
"label": "Аудит",
|
|
||||||
"icon": "shield",
|
|
||||||
"group": "server",
|
|
||||||
"order": 1.8,
|
|
||||||
"chrome": {
|
|
||||||
"serverBar": false,
|
|
||||||
"restart": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "connlog",
|
||||||
|
"label": "Подключения",
|
||||||
|
"icon": "history",
|
||||||
|
"group": "main",
|
||||||
|
"order": 1.5
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||||
// index.tsx — компонент + логика
|
// index.tsx — компонент + логика
|
||||||
// <name>.css — стили вкладки (импортит сам компонент)
|
// <name>.css — стили вкладки (импортит сам компонент)
|
||||||
// metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок)
|
// metadata.json — { id, label, icon, group?, order?, requires?, chrome? } (манифест: описание, группа меню, порядок)
|
||||||
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
||||||
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
||||||
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
||||||
@@ -35,6 +35,7 @@ export interface TabMeta {
|
|||||||
icon: string; // имя иконки → ICONS
|
icon: string; // имя иконки → ICONS
|
||||||
group: TabGroupId; // группа меню; без поля — 'main'
|
group: TabGroupId; // группа меню; без поля — 'main'
|
||||||
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
|
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
|
||||||
|
requires?: 'prometheus'; // вкладка в меню только при условии: 'prometheus' — встроенный Prometheus установлен
|
||||||
chrome?: TabChrome;
|
chrome?: TabChrome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@ function toMeta(name: string, raw: unknown): TabMeta {
|
|||||||
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
||||||
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
|
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;
|
const group = (m.group ?? 'main') as string;
|
||||||
if (!TAB_GROUPS.some(g => g.id === group)) {
|
if (!TAB_GROUPS.some(g => g.id === group)) {
|
||||||
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
|
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'),
|
icon: str('icon'),
|
||||||
group: group as TabGroupId,
|
group: group as TabGroupId,
|
||||||
order: m.order as number | undefined,
|
order: m.order as number | undefined,
|
||||||
|
requires: m.requires as 'prometheus' | undefined,
|
||||||
chrome: m.chrome as TabChrome | undefined,
|
chrome: m.chrome as TabChrome | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// 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, useMemo } from 'react';
|
||||||
|
import { apiFetch, genLabel, type NodeInfo } from '../../lib/shared';
|
||||||
|
|
||||||
|
// «Серверы для всех»: один набор правок на все мастер-ключи (POST /ui/masterkeys/servers).
|
||||||
|
// У каждого сервера — «у всех», «ни у кого» или «как есть» (если он стоит только у части ключей).
|
||||||
|
// Перед применением сервер считает, сколько ключей изменится и какие ключи API потеряют доступ к
|
||||||
|
// части из них (ключ виден API-ключу, только пока все его серверы в области).
|
||||||
|
type Pick = 'all' | 'none' | 'keep';
|
||||||
|
interface Preview { changed: number; total: number; hidden: { id: number; label: string; keys: number }[] }
|
||||||
|
|
||||||
|
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||||
|
|
||||||
|
export default function BulkServersModal({ token, nodes, keys, defaults, onClose, onDone }: {
|
||||||
|
token: string;
|
||||||
|
nodes: NodeInfo[];
|
||||||
|
keys: { servers: number[] }[]; // живые мастер-ключи
|
||||||
|
defaults: number[] | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (changed: number) => void;
|
||||||
|
}) {
|
||||||
|
const total = keys.length;
|
||||||
|
const count = (id: number) => keys.filter(k => k.servers.includes(id)).length;
|
||||||
|
const initial = (id: number): Pick => { const n = count(id); return n === total && total > 0 ? 'all' : n === 0 ? 'none' : 'keep'; };
|
||||||
|
const [picks, setPicks] = useState<Record<number, Pick>>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)])));
|
||||||
|
const [forNew, setForNew] = useState(true);
|
||||||
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const { add, remove } = useMemo(() => ({
|
||||||
|
add: nodes.filter(n => picks[n.id] === 'all' && count(n.id) < total).map(n => n.id),
|
||||||
|
remove: nodes.filter(n => picks[n.id] === 'none' && count(n.id) > 0).map(n => n.id),
|
||||||
|
}), [picks, nodes, keys]);
|
||||||
|
const all = nodes.filter(n => picks[n.id] === 'all').map(n => n.id);
|
||||||
|
const sameDefaults = JSON.stringify([...all].sort()) === JSON.stringify([...(defaults ?? [])].sort());
|
||||||
|
const body = { add, remove, ...(forNew && !sameDefaults ? { defaults: all.length ? all : null } : {}) };
|
||||||
|
const nothing = !add.length && !remove.length && !('defaults' in body);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!add.length && !remove.length) { setPreview(null); setError(''); return; }
|
||||||
|
let live = true;
|
||||||
|
apiFetch('POST', '/ui/masterkeys/servers', token, { add, remove, dry_run: true })
|
||||||
|
.then(p => { if (live) { setPreview(p); setError(''); } })
|
||||||
|
.catch(e => { if (live) { setPreview(null); setError(errText(e)); } });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [token, add, remove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
|
document.addEventListener('keydown', h);
|
||||||
|
return () => document.removeEventListener('keydown', h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { const r = await apiFetch('POST', '/ui/masterkeys/servers', token, body); onDone(r.changed ?? 0); }
|
||||||
|
catch (e) { setError(errText(e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const option = (id: number, p: Pick, label: string) => (
|
||||||
|
<button type="button" className={`bs-opt${picks[id] === p ? ' bs-opt--on' : ''}`} aria-pressed={picks[id] === p}
|
||||||
|
onClick={() => setPicks(prev => ({ ...prev, [id]: p }))}>{label}</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="node-scrim" onClick={onClose}>
|
||||||
|
<div className="node-modal bs-modal" role="dialog" aria-modal="true" aria-label="Серверы для всех ключей" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="node-modal-title">Серверы для всех ключей</h3>
|
||||||
|
<p className="node-modal-hint">
|
||||||
|
Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит
|
||||||
|
новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними.
|
||||||
|
</p>
|
||||||
|
<div className="bs-list">
|
||||||
|
{nodes.map(n => {
|
||||||
|
const c = count(n.id), partial = c > 0 && c < total;
|
||||||
|
return (
|
||||||
|
<div className="bs-row" key={n.id}>
|
||||||
|
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||||
|
<span className="chip-dot" />
|
||||||
|
</span>
|
||||||
|
<span className="bs-name">
|
||||||
|
<span className="server-pick-name">{n.health?.server || n.name}</span>
|
||||||
|
<span className="bs-sub">
|
||||||
|
{n.health?.ip && <code className="server-pick-ip">{n.health.ip}</code>}
|
||||||
|
{n.health?.gen && <span>{genLabel(n.health.gen)}</span>}
|
||||||
|
<span>сейчас у {c} из {total}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="bs-opts" role="group" aria-label={`Сервер ${n.name}`}>
|
||||||
|
{option(n.id, 'all', 'У всех')}
|
||||||
|
{partial && option(n.id, 'keep', 'Как есть')}
|
||||||
|
{option(n.id, 'none', 'Ни у кого')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="bs-check">
|
||||||
|
<input type="checkbox" checked={forNew} onChange={e => setForNew(e.target.checked)} />
|
||||||
|
<span>Новым ключам — серверы «У всех»{all.length ? '' : ' (не выбрано ни одного — как раньше: все подключённые)'}</span>
|
||||||
|
</label>
|
||||||
|
{preview && (
|
||||||
|
<p className="node-modal-hint">Изменится ключей: {preview.changed} из {preview.total}.</p>
|
||||||
|
)}
|
||||||
|
{preview?.hidden.map(h => (
|
||||||
|
<p className="mk-notice" key={h.id}>
|
||||||
|
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
|
||||||
|
Расширьте его область в «Настройках», на вкладке «API-ключи», если он ими управляет.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
<div className="node-modal-actions">
|
||||||
|
<button className="btn btn--text" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
|
||||||
|
{busy ? 'Применяем…' : 'Применить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
// Copyright (c) 2026 Ivan Vasilev
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
|
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import { apiFetch, copyText, bytes, timeAgo, getServerId, cmpVersion, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
||||||
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
import { qrDataUrl } from '../../lib/qr';
|
||||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoMore } from '../../components/icons';
|
||||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||||
import PriorityChip from '../../components/PriorityChip';
|
import PriorityChip from '../../components/PriorityChip';
|
||||||
import TorrentChip from '../../components/TorrentChip';
|
import TorrentChip from '../../components/TorrentChip';
|
||||||
|
import BulkServersModal from './BulkServersModal';
|
||||||
import './masterkeys.css';
|
import './masterkeys.css';
|
||||||
|
|
||||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||||
@@ -22,18 +23,12 @@ interface Device {
|
|||||||
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
|
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 ?? 'Ошибка запроса';
|
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||||
|
|
||||||
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
|
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
|
||||||
|
// Приоритет показываем, только когда он не обычный: «обычный» в каждой строке — шум
|
||||||
|
const bgOnly = (p?: Priority) => p === 'background' ? p : undefined;
|
||||||
|
|
||||||
const statusChip = (d: Device) => (
|
const statusChip = (d: Device) => (
|
||||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||||
<span className="chip-dot" />
|
<span className="chip-dot" />
|
||||||
@@ -54,11 +49,18 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [tls, setTls] = useState(false);
|
const [tls, setTls] = useState(false);
|
||||||
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
|
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
|
||||||
|
// Маскировка первого пакета (I1) сервера из шапки: false — сервер её не умеет (старый awg-ctrl) или недоступен
|
||||||
|
const [masking, setMasking] = useState<'ready' | 'busy' | false>(false);
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [limit, setLimit] = useState('3');
|
const [limit, setLimit] = useState('3');
|
||||||
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
||||||
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
|
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
|
||||||
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
|
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 [limitEdit, setLimitEdit] = useState('');
|
||||||
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
|
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
|
||||||
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
|
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
|
||||||
@@ -66,6 +68,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
|
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
|
||||||
const creatingRef = useRef(false);
|
const creatingRef = useRef(false);
|
||||||
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
|
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
|
||||||
|
const [defaults, setDefaults] = useState<number[] | null>(null); // серверы новых ключей из «Серверы для всех»
|
||||||
|
const [bulk, setBulk] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const seq = ++loadSeq.current;
|
const seq = ++loadSeq.current;
|
||||||
@@ -73,7 +77,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||||
const list: MasterKey[] = d.keys ?? [];
|
const list: MasterKey[] = d.keys ?? [];
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
|
||||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||||
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
|
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
|
||||||
const live = list.filter(k => !k.deleting);
|
const live = list.filter(k => !k.deleting);
|
||||||
@@ -106,15 +110,32 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
if (!label.trim() || creatingRef.current) return;
|
if (!label.trim() || creatingRef.current) return;
|
||||||
creatingRef.current = true; setCreating(true);
|
creatingRef.current = true; setCreating(true);
|
||||||
try { await act(async () => {
|
try { await act(async () => {
|
||||||
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
|
// Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми,
|
||||||
|
// которые уже подключались; набор потом правится в настройках ключа.
|
||||||
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
||||||
await apiFetch('POST', '/ui/masterkeys', token, {
|
await apiFetch('POST', '/ui/masterkeys', token, {
|
||||||
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
|
label: label.trim(), device_limit: Number(limit), ...(!defaults && ready.length ? { servers: ready } : {}),
|
||||||
});
|
});
|
||||||
setLabel('');
|
setLabel('');
|
||||||
}, 'Мастер-ключ создан'); }
|
}, 'Мастер-ключ создан'); }
|
||||||
finally { creatingRef.current = false; setCreating(false); }
|
finally { creatingRef.current = false; setCreating(false); }
|
||||||
}, [label, limit, nodes, token, act]);
|
}, [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 serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
|
||||||
|
|
||||||
@@ -136,8 +157,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const showQR = useCallback(async (k: MasterKey) => {
|
const showQR = useCallback(async (k: MasterKey) => {
|
||||||
try {
|
try {
|
||||||
const { link, tls: t } = await fetchLink(k);
|
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: qrDataUrl(link, 'H') });
|
||||||
setModal({ label: k.label, link, tls: t, dataUrl });
|
|
||||||
} catch (e) { showMsg(errText(e)); }
|
} catch (e) { showMsg(errText(e)); }
|
||||||
}, [fetchLink, showMsg]);
|
}, [fetchLink, showMsg]);
|
||||||
|
|
||||||
@@ -190,12 +210,48 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</span>
|
</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) => (
|
const actions = (k: MasterKey) => (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
|
<span className="tip-wrap tip-wrap--end" data-tip="QR-код"><button className="btn-icon" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button></span>
|
||||||
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button>
|
{moreButton('key', k.id, `Ещё: ${k.label}`, true)}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -270,7 +326,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
|
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
|
||||||
const meter = (k: MasterKey, n: number) => (
|
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}
|
role="progressbar" aria-label="Устройства" aria-valuemin={0}
|
||||||
aria-valuemax={k.device_limit} aria-valuenow={n}
|
aria-valuemax={k.device_limit} aria-valuenow={n}
|
||||||
title={`Устройств: ${n} из ${k.device_limit}`}>
|
title={`Устройств: ${n} из ${k.device_limit}`}>
|
||||||
@@ -283,22 +339,35 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</div>
|
</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) => (
|
const deviceActions = (d: Device) => (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
|
<span className="tip-wrap tip-wrap--end" data-tip="Новый PSK"><button className="btn-icon" aria-label="Новый PSK" onClick={() => newPsk(d)}><IcoRefresh /></button></span>
|
||||||
try {
|
<span className="tip-wrap tip-wrap--end" data-tip="Отозвать устройство"><button className="btn-icon btn-icon--danger" aria-label="Отозвать устройство" onClick={() => revoke(d)}><IcoTrash /></button></span>
|
||||||
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>
|
|
||||||
</div>
|
</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 — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
|
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
|
||||||
// по клику копируется.
|
// по клику копируется.
|
||||||
const nameCell = (k: MasterKey) => (
|
const nameCell = (k: MasterKey) => (
|
||||||
@@ -320,72 +389,146 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
|
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
|
// Серверы ключа, куда ещё не добавлено устройство: был не в сети, когда оно подключалось или меняло
|
||||||
const summary = (list: Device[]) => {
|
// ключ; панель добавит его сама, когда сервер появится (reconcile). Без ips (старая панель) — неизвестно.
|
||||||
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
|
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 on = list.filter(d => d.online).length;
|
||||||
const last = Math.max(...list.map(d => d.lastHandshake || 0));
|
const last = Math.max(...list.map(d => d.lastHandshake || 0));
|
||||||
const torrent = list.some(d => d.torrent);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<td>
|
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
|
||||||
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
|
<span className="chip-dot" />
|
||||||
<span className="chip-dot" />
|
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
|
||||||
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
|
</span>
|
||||||
</span>
|
<PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} />
|
||||||
</td>
|
</>
|
||||||
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td>
|
);
|
||||||
<td colSpan={2}></td>
|
};
|
||||||
|
|
||||||
|
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
|
||||||
|
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.rx, 0))}</td>
|
||||||
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
|
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
|
// Один адрес — выбранного в шапке сервера, остальные — «+N» и подсказка со всеми. Адрес с другого
|
||||||
|
// сервера подписан его именем строкой ниже, иначе его примут за адрес выбранного.
|
||||||
const ipCell = (d: Device) => {
|
const ipCell = (d: Device) => {
|
||||||
const all = d.ips ?? [];
|
const all = d.ips ?? [];
|
||||||
const { ip, serverId, other } = ipPick(d);
|
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
|
return all.length > 1
|
||||||
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
|
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{body}</span>
|
||||||
: text;
|
: 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' : ''}`}
|
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||||
onClick={() => fold(k.id)}>▾</button>
|
onClick={() => fold(k.id)}>▾</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const deviceNode = (k: MasterKey, d: Device) => (
|
// Телефон: устройство — строка списка в карточке ключа, а не карточка в карточке. Имя, статус и «⋮»;
|
||||||
<li className="mk-node mk-node--device" key={d.id}>
|
// ниже мелко — платформа, версия, IP; трафик, приоритет и то, на скольких серверах оно есть.
|
||||||
<div className="mk-device">
|
const deviceRow = (k: MasterKey, d: Device) => {
|
||||||
<div className="mk-device-main">
|
const all = d.ips ?? [];
|
||||||
<span className="mk-device-title">
|
const { ip, serverId, other } = ipPick(d);
|
||||||
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
|
return (
|
||||||
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
<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>
|
||||||
<span className="mk-device-sub">{platformLabel(d)}
|
{statusChip(d)}
|
||||||
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
|
{moreButton('device', d.id, `Ещё: ${d.device_name || 'устройство'}`)}
|
||||||
</div>
|
</div>
|
||||||
{statusChip(d)}
|
<div className="mk-dev-meta">
|
||||||
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
|
<span>{d.platform || '—'}</span>
|
||||||
<div className="mk-device-ip">
|
{versionTag(d, 'start')}
|
||||||
<IcoGlobe />
|
<span aria-hidden="true">·</span>
|
||||||
{ipCell(d)}
|
<code className="mk-ip">{ip}</code>
|
||||||
<PriorityChip priority={d.priority} torrent={d.torrent} />
|
{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} />
|
<TorrentChip torrent={d.torrent} />
|
||||||
|
{syncFlag(k, d) && <span>{syncFlag(k, d)}</span>}
|
||||||
|
{d.rekey_requested && <span>ждёт смены ключа</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mk-device-traffic">
|
</li>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -396,6 +539,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
|
<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">
|
<div className="toolbar">
|
||||||
<input className="field" placeholder="Метка (напр. Семья)" value={label}
|
<input className="field" placeholder="Метка (напр. Семья)" value={label}
|
||||||
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
|
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
|
||||||
@@ -403,6 +559,9 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
|
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
|
||||||
<div className="toolbar-btns">
|
<div className="toolbar-btns">
|
||||||
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -411,65 +570,56 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<table className="mk-table">
|
<table className="mk-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
|
<tr><td colSpan={8} className="empty">Нет мастер-ключей</td></tr>
|
||||||
) : keys.map(k => {
|
) : keys.map(k => {
|
||||||
if (k.deleting) return (
|
if (k.deleting) return (
|
||||||
<tr key={k.id} className="mk-tr-master mk-last">
|
<tr key={k.id} className="mk-tr-master mk-last">
|
||||||
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
|
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
|
||||||
<td colSpan={8}>{deletingChip(k)}</td>
|
<td colSpan={7}>{deletingChip(k)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
const list = devs[k.id] ?? [];
|
const list = devs[k.id] ?? [];
|
||||||
const count = devs[k.id]?.length ?? k.devices;
|
const count = devs[k.id]?.length ?? k.devices;
|
||||||
const free = Math.max(0, k.device_limit - count);
|
|
||||||
const open = !folded.has(k.id);
|
const open = !folded.has(k.id);
|
||||||
return (
|
return (
|
||||||
<Fragment key={k.id}>
|
<Fragment key={k.id}>
|
||||||
<tr className="mk-tr-master">
|
<tr className="mk-tr-master">
|
||||||
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
|
<td><span className="mk-name">{caret(k, count)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
|
||||||
{summary(list)}
|
{summary(k, list, count === 0)}
|
||||||
<td>{meter(k, count)}</td>
|
<td>{meter(k, count)}</td>
|
||||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||||
</tr>
|
</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) => (
|
{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>
|
<td>
|
||||||
<span className="mk-indent">
|
<span className="mk-indent">
|
||||||
<span className="name-stack">
|
<span className="name-stack">
|
||||||
{d.device_name || 'Устройство'}
|
{d.device_name || 'Устройство'}
|
||||||
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
|
{/* пометки — в строке ID, не рядом с именем: так колонка «Имя» уже и таблица влезает */}
|
||||||
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
|
<span className="mk-id-line">
|
||||||
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
|
{deviceId(d)}
|
||||||
{d.device_id.slice(0, 8)}…
|
{syncFlag(k, d) && <span className="tip-wrap tip-wrap--start mk-flag" data-tip={syncTip(k, d)}>· {syncFlag(k, d)}</span>}
|
||||||
</button>
|
{d.rekey_requested && <span className="mk-flag">· ждёт смены ключа</span>}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
|
|
||||||
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
|
|
||||||
</td>
|
</td>
|
||||||
<td>{statusChip(d)}</td>
|
<td><span className="user-chips">{statusChip(d)}<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
|
||||||
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
|
<td>{ipCell(d)}</td>
|
||||||
<td className="td-mono">{ipCell(d)}</td>
|
<td>{platformCell(d)}</td>
|
||||||
<td>{platformLabel(d)}</td>
|
|
||||||
<td>{bytes(d.rx)}</td>
|
<td>{bytes(d.rx)}</td>
|
||||||
<td>{bytes(d.tx)}</td>
|
<td>{bytes(d.tx)}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{open && free > 0 && (
|
|
||||||
<tr className="mk-tr-free mk-last">
|
|
||||||
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -477,7 +627,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */}
|
{/* Телефон и планшет: таблица скрыта (.table-card) — карточка на ключ: шапка, сводка, действия,
|
||||||
|
ниже списком его устройства */}
|
||||||
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
||||||
<ul className="mk-tree">
|
<ul className="mk-tree">
|
||||||
{keys.map(k => {
|
{keys.map(k => {
|
||||||
@@ -485,39 +636,70 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<li className="mk-root" key={k.id}>
|
<li className="mk-root" key={k.id}>
|
||||||
<div className="mk-master">
|
<div className="mk-master">
|
||||||
<IcoKey />
|
<IcoKey />
|
||||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
<span className="mk-master-label">{nameCell(k)}</span>
|
||||||
{deletingChip(k)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mk-master-sum">{deletingChip(k)}</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
const list = devs[k.id] ?? [];
|
const list = devs[k.id] ?? [];
|
||||||
const count = devs[k.id]?.length ?? k.devices;
|
const count = devs[k.id]?.length ?? k.devices;
|
||||||
const free = Math.max(0, k.device_limit - count);
|
|
||||||
const isFolded = folded.has(k.id);
|
const isFolded = folded.has(k.id);
|
||||||
return (
|
return (
|
||||||
<li className="mk-root" key={k.id}>
|
<li className="mk-root" key={k.id}>
|
||||||
<div className="mk-master">
|
<div className="mk-master">
|
||||||
{caret(k)}
|
{caret(k, count)}
|
||||||
<IcoKey />
|
<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)}
|
{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>
|
</div>
|
||||||
{tools === k.id && toolbox(k)}
|
{tools === k.id && toolbox(k)}
|
||||||
{!isFolded && (
|
{!isFolded && list.length > 0 && <ul className="mk-devs">{list.map(d => deviceRow(k, d))}</ul>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{modal && (
|
{modal && (
|
||||||
<div className="qr-backdrop" onClick={() => setModal(null)}>
|
<div className="qr-backdrop" onClick={() => setModal(null)}>
|
||||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||||
@@ -526,8 +708,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
|
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
|
||||||
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
|
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
|
||||||
<div className="qr-actions">
|
<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--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
|
||||||
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,70 +7,84 @@ LICENSE file in the root directory of this source tree.
|
|||||||
Общая дизайн-система — в App.css. */
|
Общая дизайн-система — в App.css. */
|
||||||
|
|
||||||
.mk-notice {
|
.mk-notice {
|
||||||
font-size: 13px;
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||||
color: var(--error-text);
|
color: var(--on-error-container);
|
||||||
background: var(--error-bg);
|
background: var(--error-container);
|
||||||
border-radius: 16px;
|
border-radius: var(--shape-large);
|
||||||
padding: 10px 16px;
|
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-limit-input { width: 88px !important; }
|
||||||
|
|
||||||
/* ── таблица (десктоп) ── */
|
/* ── таблица (десктоп) ── */
|
||||||
.mk-table td, .mk-table th { white-space: nowrap; }
|
.mk-table td, .mk-table th { white-space: nowrap; }
|
||||||
.mk-table .td-actions { width: 150px; }
|
.mk-table .td-actions { width: 80px; } /* QR и «⋮» */
|
||||||
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
|
|
||||||
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
|
.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:first-child, .mk-table td:first-child { padding-left: 20px; }
|
||||||
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
|
.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-tr-master:hover td { background: var(--surface-container); }
|
||||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
.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-name .mk-caret { margin-left: -8px; }
|
||||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
.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 td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
|
||||||
.mk-tr-tools:hover { background: transparent; }
|
.mk-tr-tools:hover { background: transparent; }
|
||||||
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
|
.mk-tr-device td { padding-top: 10px; padding-bottom: 10px; }
|
||||||
.mk-flag { color: var(--neutral-text); font-size: 12px; }
|
.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-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;
|
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 { position: relative; display: inline-block; padding-left: 36px; }
|
||||||
.mk-indent::before {
|
.mk-indent::before {
|
||||||
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
|
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
|
||||||
border-top: 2px solid var(--outline);
|
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-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
|
||||||
|
|
||||||
.mk-root {
|
.mk-root {
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: var(--r-card);
|
border-radius: var(--shape-extra-large);
|
||||||
padding: 12px 18px 14px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* родитель */
|
/* шапка ключа: стрелка, ключ, метка с коротким UUID, полоска устройств */
|
||||||
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
|
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
|
||||||
.mk-master-label {
|
.mk-master-label {
|
||||||
font-size: 16px;
|
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
|
||||||
font-weight: 500;
|
font-weight: var(--weight-medium);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -83,13 +97,38 @@ LICENSE file in the root directory of this source tree.
|
|||||||
border: 0; border-radius: 50%;
|
border: 0; border-radius: 50%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
font-size: 16px;
|
font-size: var(--body-large-size); line-height: var(--body-large-line);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
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--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 {
|
.mk-tools {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -103,10 +142,10 @@ LICENSE file in the root directory of this source tree.
|
|||||||
display: flex; flex-direction: column; gap: 10px;
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: 16px;
|
border-radius: var(--shape-large);
|
||||||
padding: 12px 16px 14px;
|
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-box .mk-box-title { margin-bottom: 2px; }
|
||||||
/* строка настройки: слева название и пояснение, справа контрол */
|
/* строка настройки: слева название и пояснение, справа контрол */
|
||||||
.mk-setting {
|
.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-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
|
||||||
.mk-setting:last-child { padding-bottom: 0; }
|
.mk-setting:last-child { padding-bottom: 0; }
|
||||||
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 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-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: 12px; color: var(--neutral-text); }
|
.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-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
.mk-uuid {
|
.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%;
|
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; }
|
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||||
|
|
||||||
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
|
/* прогресс занятых слотов с числом внутри: обычная заливка primary, цветом — только когда мест
|
||||||
|
не осталось (тёплый: это повод присмотреться, а не ошибка).
|
||||||
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
|
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
|
||||||
.mk-meter {
|
.mk-meter {
|
||||||
--mk-w: 136px;
|
--mk-w: 136px;
|
||||||
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
|
--meter-fill: var(--primary);
|
||||||
--meter-on: #2a2118; /* цвет числа поверх заливки */
|
--meter-on: var(--on-primary); /* цвет числа поверх заливки */
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--mk-w); height: 20px;
|
width: var(--mk-w); height: 20px;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--shape-full);
|
||||||
background: var(--outline-variant);
|
background: var(--outline-variant);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
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;
|
position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
background: var(--meter-fill);
|
background: var(--meter-fill);
|
||||||
overflow: hidden;
|
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 {
|
.mk-meter-text {
|
||||||
@@ -153,90 +193,62 @@ LICENSE file in the root directory of this source tree.
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 11px;
|
padding: 0 11px;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
}
|
}
|
||||||
.mk-meter-text--on { color: var(--meter-on); }
|
.mk-meter-text--on { color: var(--meter-on); }
|
||||||
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */
|
.mk-meter--full { --meter-fill: var(--warn-dot); --meter-on: #2a2118; } /* все места заняты */
|
||||||
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
|
|
||||||
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
|
|
||||||
|
|
||||||
.mk-box .tip-wrap { width: 100%; }
|
.mk-box .tip-wrap { width: 100%; }
|
||||||
|
|
||||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
/* сводка (статус, серверы) и действия ключа */
|
||||||
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); }
|
.mk-master-sum { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
|
||||||
.mk-node { position: relative; padding: 4px 0; }
|
.mk-master-sum:empty { display: none; }
|
||||||
.mk-node::before {
|
.mk-master-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||||||
content: '';
|
.mk-master-actions .btn { flex: 1; }
|
||||||
position: absolute;
|
.mk-tree .mk-more { width: 40px; height: 40px; }
|
||||||
left: -28px; top: 50%;
|
/* на телефоне .tip-wrap по умолчанию во всю ширину — здесь чипы и ID идут в строку */
|
||||||
width: 28px;
|
.mk-tree .tip-wrap { width: auto; }
|
||||||
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-device {
|
/* устройства ключа — строками списка под ним, разделены линией */
|
||||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
.mk-devs { margin: 12px -16px -12px; border-top: 1px solid var(--outline-variant); }
|
||||||
background: var(--surface-container);
|
.mk-dev { padding: 10px 16px; }
|
||||||
border-radius: 14px;
|
.mk-dev + .mk-dev { border-top: 1px solid var(--outline-variant); }
|
||||||
padding: 8px 14px;
|
.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-dev-head .mk-more { margin: -4px -8px -4px 0; }
|
||||||
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
.mk-dev-meta {
|
||||||
.mk-device-id {
|
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px;
|
||||||
font-family: var(--mono);
|
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
|
||||||
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-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
.mk-dev-meta .chip { font-size: var(--body-small-size); }
|
||||||
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
|
.mk-dev-traffic { font-family: var(--mono); white-space: pre; }
|
||||||
.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-free {
|
/* ── телефон и планшет (design.md, часть IV): карточка на ключ, устройства строками, тач-цели ≥ 44px ── */
|
||||||
border: 1px dashed var(--outline);
|
/* compact и medium (< 840px): карточки вместо таблицы */
|
||||||
border-radius: 14px;
|
@media (max-width: 839px) {
|
||||||
padding: 10px 14px;
|
.mk-warn { flex-wrap: wrap; padding: 12px 12px 12px 16px; }
|
||||||
font-size: 13px;
|
.mk-warn-text { flex-basis: 100%; }
|
||||||
color: var(--neutral-dot);
|
.mk-warn .btn { margin-left: auto; }
|
||||||
}
|
|
||||||
|
|
||||||
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
|
|
||||||
50/50, действия рядом с большим пальцем ── */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.mk-tree { display: flex; }
|
.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-limit-input { width: 100% !important; }
|
||||||
|
|
||||||
.mk-root { padding: 12px; }
|
.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 { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
|
||||||
|
.mk-caret-gap { width: 44px; margin-left: -8px; }
|
||||||
.mk-meter { --mk-w: 112px; height: 24px; }
|
.mk-meter { --mk-w: 112px; height: 24px; }
|
||||||
|
|
||||||
/* настройки узла: поля и кнопки на всю ширину */
|
/* настройки узла: поля и кнопки на всю ширину */
|
||||||
@@ -246,36 +258,32 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.mk-setting-ctl .btn { flex: 1; height: 44px; }
|
.mk-setting-ctl .btn { flex: 1; height: 44px; }
|
||||||
.mk-setting-ctl .mk-limit-input { width: 100% !important; 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-tools .btn { width: 100%; height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
/* дерево уже: отступы поменьше, «ножки» короче */
|
/* «Серверы для всех» (BulkServersModal) */
|
||||||
.mk-children { margin-left: 8px; padding-left: 14px; }
|
.bs-modal { max-width: 620px; }
|
||||||
.mk-node { padding: 4px 0; }
|
.bs-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.mk-node::before { left: -14px; width: 14px; }
|
.bs-row {
|
||||||
.mk-node:last-child::after { left: -16px; }
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: var(--surface-container); border-radius: var(--shape-medium); padding: 8px 8px 8px 14px;
|
||||||
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
|
||||||
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
|
}
|
||||||
.mk-device-main { flex: 1 1 0; }
|
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||||
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
|
.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); }
|
||||||
.mk-device-id { max-width: 100%; }
|
.bs-sub .server-pick-ip { margin-left: 0; }
|
||||||
.mk-device-traffic { width: 100%; gap: 8px; }
|
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
.mk-metric {
|
.bs-opt {
|
||||||
flex: 1; flex-direction: column; gap: 2px;
|
min-height: 36px; padding: 0 12px;
|
||||||
background: var(--surface-bright);
|
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
|
||||||
border-radius: 16px; padding: 8px 12px;
|
background: var(--surface-bright);
|
||||||
}
|
border: 1px solid var(--outline); border-radius: var(--shape-full);
|
||||||
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
|
cursor: pointer; white-space: nowrap;
|
||||||
.mk-metric-name::after { content: attr(data-sfx); }
|
}
|
||||||
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
.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; }
|
||||||
/* кнопки действий: контурные, на всю ширину ряда, 44px */
|
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
|
||||||
.mk-device .actions { width: 100%; gap: 8px; }
|
@media (max-width: 599px) {
|
||||||
.mk-master .btn-icon, .mk-device .btn-icon {
|
.bs-row { flex-wrap: wrap; }
|
||||||
flex: 1; width: auto; height: 44px;
|
.bs-opts { width: 100%; }
|
||||||
border: 1px solid var(--outline);
|
.bs-opt { flex: 1; }
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-151
@@ -2,19 +2,16 @@
|
|||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
|
import { apiFetch, getServerId, openTab, announcePrometheus, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||||
import { IcoCopy, IcoTrash } from '../../components/icons';
|
import { IcoGear } from '../../components/icons';
|
||||||
|
import type { MonitorStatus as Status } from '../settings/prometheus';
|
||||||
import LineChart from '../../components/LineChart';
|
import LineChart from '../../components/LineChart';
|
||||||
|
import ServerSelect, { ALL_SERVERS } from '../../components/ServerSelect';
|
||||||
import './monitor.css';
|
import './monitor.css';
|
||||||
|
|
||||||
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
|
// Вкладка «Монитор»: основные графики со встроенного Prometheus. Ставится и удаляется он в «Настройках»,
|
||||||
// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root;
|
// раздел «Prometheus» (tabs/settings/prometheus); пока его нет, здесь — карточка со ссылкой туда.
|
||||||
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
|
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
|
||||||
// (awg-ui/monitor.ts).
|
|
||||||
interface Status {
|
|
||||||
ready: boolean; can_install: boolean; command: string;
|
|
||||||
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
|
|
||||||
}
|
|
||||||
type Pts = [number, number][];
|
type Pts = [number, number][];
|
||||||
interface Overview {
|
interface Overview {
|
||||||
range: string; step: number; from: number; to: number;
|
range: string; step: number; from: number; to: number;
|
||||||
@@ -26,6 +23,24 @@ interface Overview {
|
|||||||
total: Series;
|
total: Series;
|
||||||
}
|
}
|
||||||
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
|
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 дней']];
|
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
|
||||||
|
|
||||||
@@ -50,28 +65,6 @@ function Bar({ v, label }: { v: number | null | undefined; label: string }) {
|
|||||||
</span>
|
</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)} ГБ`;
|
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
|
||||||
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
|
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
|
||||||
function vol(b: number): string {
|
function vol(b: number): string {
|
||||||
@@ -80,39 +73,42 @@ function vol(b: number): string {
|
|||||||
return `${nf(b / 1e6, 0)} МБ`;
|
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 [status, setStatus] = useState<Status | null>(null);
|
||||||
const [range, setRange] = useState('1h');
|
const [range, setRange] = useState('1h');
|
||||||
const [ov, setOv] = useState<Overview | null>(null);
|
const [ov, setOv] = useState<Overview | null>(null);
|
||||||
const [ovErr, setOvErr] = useState('');
|
const [ovErr, setOvErr] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки
|
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||||
const [server, setServer] = useState<NodeInfo | null>(null); // показанный сервер: выбран в другой вкладке, здесь не меняется
|
// Чьи графики: сервер (по умолчанию — выбранный в шапке) или ALL_SERVERS. Выбор свой, шапку не меняет.
|
||||||
|
const [view, setView] = useState(() => getServerId() ?? 0);
|
||||||
const alive = useRef(true);
|
const alive = useRef(true);
|
||||||
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
|
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
|
||||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
try { const s = await apiFetch('GET', '/ui/monitor', 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; }
|
catch { return null; }
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const loadOverview = useCallback(async () => {
|
const loadOverview = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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(''); }
|
if (alive.current) { setOv(o); setOvErr(''); }
|
||||||
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
|
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
|
||||||
finally { if (alive.current) setLoading(false); }
|
finally { if (alive.current) setLoading(false); }
|
||||||
}, [token, range]);
|
}, [token, range, view]);
|
||||||
|
|
||||||
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
||||||
|
|
||||||
// Название и состояние сервера для заголовка; список серверов на этой вкладке не показываем
|
// Серверы для выбора (и их связь — точки на плашках)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = () => apiFetch('GET', '/ui/nodes', token)
|
const load = () => apiFetch('GET', '/ui/nodes', token)
|
||||||
.then(r => { if (alive.current) setServer((r.nodes as NodeInfo[]).find(n => n.id === (getServerId() ?? 0)) ?? null); })
|
.then(r => { if (alive.current) setNodes(r.nodes ?? []); })
|
||||||
.catch(() => { /* заголовок необязателен */ });
|
.catch(() => { /* выбор необязателен — останется текущий сервер */ });
|
||||||
void load();
|
void load();
|
||||||
const t = setInterval(load, 15000);
|
const t = setInterval(load, 15000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
@@ -126,113 +122,43 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [status?.ready, loadOverview]);
|
}, [status?.ready, loadOverview]);
|
||||||
|
|
||||||
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
|
// Пока Prometheus нет — раз в 10 с проверяем: его могли поставить в «Настройках»
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pending && !status?.job?.running) return;
|
if (!status || status.ready) return;
|
||||||
const t = setInterval(async () => {
|
const t = setInterval(() => { void loadStatus(); }, 10000);
|
||||||
const s = await loadStatus();
|
|
||||||
if (s && !s.job?.running) setPending(null);
|
|
||||||
}, 2000);
|
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [pending, status?.job?.running, loadStatus]);
|
}, [status, loadStatus]);
|
||||||
|
|
||||||
const run = async (action: 'install' | 'remove') => {
|
// Prometheus нет — графиков не будет: ведём в «Настройки», где он ставится
|
||||||
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
|
|
||||||
setPending(action);
|
|
||||||
try { await apiFetch('POST', `/ui/monitor/${action}`, 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ───────── установка ─────────
|
|
||||||
const setup = status && !status.ready && (
|
const setup = status && !status.ready && (
|
||||||
<section className="mon-card" aria-labelledby="mon-setup">
|
<section className="mon-card" aria-labelledby="mon-setup">
|
||||||
<div className="mon-head">
|
<div className="mon-head">
|
||||||
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
|
<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>
|
</div>
|
||||||
<p className="mon-hint">
|
<p className="mon-hint">
|
||||||
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
|
Графики — скорость, устройства онлайн, нагрузка на канал — строит встроенный Prometheus.
|
||||||
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
|
Он ставится в «Настройках», на вкладке «Prometheus»; графики появятся здесь через минуту-две после установки.
|
||||||
только 127.0.0.1, наружу он не открывается.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mon-actions">
|
||||||
{status.can_install ? (
|
<button className="btn btn--primary" onClick={() => openTab('settings', 'prom')}>
|
||||||
<div className="mon-actions">
|
<IcoGear /> Открыть настройки
|
||||||
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
|
</button>
|
||||||
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
|
|
||||||
</button>
|
|
||||||
{progressFor('any')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</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>
|
</div>
|
||||||
<p className="mon-hint">
|
|
||||||
Стоит на этом сервере и слушает только 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">Выполните на сервере от root:</p>
|
|
||||||
<code className="mon-code">awg-ctrl monitor remove</code>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status.job?.action === 'remove' && failureLog()}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ───────── дашборд ─────────
|
// ───────── дашборд ─────────
|
||||||
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново.
|
// Один сервер или сводка по всем (sumUp) — плитки читают одно и то же
|
||||||
const one = ov?.servers[0];
|
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 data = ov?.total ?? null;
|
||||||
const cur = data && {
|
const cur = data && {
|
||||||
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
|
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
|
||||||
@@ -245,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 unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
|
||||||
const dashboard = status?.ready && (
|
const dashboard = status?.ready && (
|
||||||
<>
|
<>
|
||||||
{/* Период — единственный фильтр: он задаёт окно для всего ниже */}
|
{/* Период слева задаёт окно для всего ниже; справа — чьи графики: кнопка и меню, как выбор
|
||||||
<div className="mon-filters" role="group" aria-label="Период">
|
сервера в шапке, «Все серверы» — сводка по всем */}
|
||||||
{RANGES.map(([id, label]) => (
|
<div className="mon-toolbar">
|
||||||
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
|
<div className="mon-filters" role="group" aria-label="Период">
|
||||||
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
|
{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>
|
</div>
|
||||||
|
|
||||||
{ovErr && <p className="login-error">{ovErr}</p>}
|
{ovErr && <p className="login-error">{ovErr}</p>}
|
||||||
{ov && !one?.ok && (
|
{ov && !all && !one?.ok && (
|
||||||
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
|
<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-tiles">
|
||||||
<div className="mon-tile">
|
<div className="mon-tile">
|
||||||
@@ -299,14 +237,14 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
|||||||
<div className="mon-tile-meter">
|
<div className="mon-tile-meter">
|
||||||
<span className="mon-tile-value mon-tile-rate">
|
<span className="mon-tile-value mon-tile-rate">
|
||||||
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
|
{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>
|
</span>
|
||||||
<Bar v={one?.cpu} label="Загрузка процессора" />
|
<Bar v={one?.cpu} label="Загрузка процессора" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mon-tile-meter">
|
<div className="mon-tile-meter">
|
||||||
<span className="mon-tile-value mon-tile-rate">
|
<span className="mon-tile-value mon-tile-rate">
|
||||||
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
|
{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>
|
</span>
|
||||||
<Bar v={one?.mem} label="Занято оперативной памяти" />
|
<Bar v={one?.mem} label="Занято оперативной памяти" />
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +252,33 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{ov && data && (
|
||||||
<div className="mon-charts">
|
<div className="mon-charts">
|
||||||
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||||
@@ -344,18 +309,9 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
return (
|
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>}
|
{!status && <p className="mon-hint">Загрузка…</p>}
|
||||||
{setup}
|
{setup}
|
||||||
{dashboard}
|
{dashboard}
|
||||||
{manage}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"icon": "pulse",
|
"icon": "pulse",
|
||||||
"group": "server",
|
"group": "server",
|
||||||
"order": 1.7,
|
"order": 1.7,
|
||||||
|
"requires": "prometheus",
|
||||||
"chrome": {
|
"chrome": {
|
||||||
"serverBar": false,
|
"serverBar": false,
|
||||||
"restart": false
|
"restart": false
|
||||||
|
|||||||
@@ -8,25 +8,20 @@ LICENSE file in the root directory of this source tree.
|
|||||||
display: flex; flex-direction: column; gap: 12px;
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: var(--r-card);
|
border-radius: var(--shape-extra-large);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
.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-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: 12px; color: var(--neutral-text); }
|
.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-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
|
||||||
.mon-code {
|
@media (max-width: 599px) {
|
||||||
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) {
|
|
||||||
.mon-actions .btn { flex: 1; }
|
.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 { --series-1: #6a45b8; --series-2: #c4620a; }
|
||||||
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
|
: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 {
|
.mon-range {
|
||||||
min-height: 44px; padding: 0 18px;
|
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);
|
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;
|
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: var(--weight-medium); }
|
||||||
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
|
|
||||||
|
|
||||||
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||||
.mon-tile {
|
.mon-tile {
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
background: var(--surface-bright);
|
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;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
.mon-tile-label { 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: 20px; font-weight: 500; color: var(--on-surface); }
|
.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: 12px; color: var(--neutral-text); }
|
.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-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-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-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-meter { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
|
.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 {
|
.mon-chart {
|
||||||
margin: 0; min-width: 0;
|
margin: 0; min-width: 0;
|
||||||
background: var(--surface-bright);
|
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;
|
padding: 16px;
|
||||||
transition: opacity .15s;
|
transition: opacity var(--duration-short3) var(--ease-standard);
|
||||||
}
|
}
|
||||||
.mon-chart--dim { opacity: .6; }
|
.mon-chart--dim { opacity: .6; }
|
||||||
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
|
.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-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: 12px; color: var(--neutral-text); }
|
.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-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 {
|
.mon-chart-toggle {
|
||||||
margin-left: auto; min-height: 44px; padding: 0 12px;
|
margin-left: auto; min-height: 44px; padding: 0 12px;
|
||||||
font: inherit; font-size: 13px; color: var(--on-primary-container);
|
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(--r-pill); cursor: pointer;
|
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 { position: relative; }
|
||||||
.mon-plot svg { display: block; max-width: 100%; }
|
.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-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-cross { stroke: var(--outline); stroke-width: 1; }
|
||||||
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
|
.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 {
|
.mon-tip {
|
||||||
position: absolute; pointer-events: none; z-index: 2;
|
position: absolute; pointer-events: none; z-index: 2;
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
background: var(--surface-container);
|
background: var(--surface-container);
|
||||||
border: 1px solid var(--outline-variant); border-radius: 12px;
|
border: 1px solid var(--outline-variant); border-radius: var(--shape-medium);
|
||||||
padding: 8px 10px; font-size: 12px; color: var(--neutral-text);
|
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-time { color: var(--neutral-text); }
|
||||||
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
|
.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-wrap { overflow-x: auto; }
|
||||||
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.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: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; }
|
.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-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-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 { 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: 4px; background: var(--series-1); transition: width .3s; }
|
.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--warn { background: var(--warn-dot); }
|
||||||
.mon-bar i.mon-bar--bad { background: var(--error-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-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; } }
|
|
||||||
|
|
||||||
|
|||||||
+19
-16
@@ -8,11 +8,11 @@ LICENSE file in the root directory of this source tree.
|
|||||||
/* Открытый API-ключ (показывается один раз при создании) */
|
/* Открытый API-ключ (показывается один раз при создании) */
|
||||||
.apikey-value {
|
.apikey-value {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
background: var(--surface-container);
|
background: var(--surface-container);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: 12px;
|
border-radius: var(--shape-medium);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
word-break: break-all;
|
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-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 {
|
.apikey-empty {
|
||||||
font-size: 14px;
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 0;
|
padding: 48px 0;
|
||||||
@@ -31,7 +31,7 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.apikey-card {
|
.apikey-card {
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: var(--r-card);
|
border-radius: var(--shape-extra-large);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -49,10 +49,10 @@ LICENSE file in the root directory of this source tree.
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
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 {
|
.apikey-card-label {
|
||||||
font-size: 16px;
|
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
|
||||||
font-weight: 500;
|
font-weight: var(--weight-medium);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -60,7 +60,7 @@ LICENSE file in the root directory of this source tree.
|
|||||||
}
|
}
|
||||||
.apikey-card-prefix {
|
.apikey-card-prefix {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -68,12 +68,15 @@ LICENSE file in the root directory of this source tree.
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 12px;
|
font-size: var(--body-small-size); line-height: var(--body-small-line);
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
/* compact и medium (< 840px): карточки вместо таблицы, на планшете — сеткой */
|
||||||
.apikey-cards-list { display: flex; }
|
@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;
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: 16px;
|
border-radius: var(--shape-large);
|
||||||
padding: 12px 16px 14px;
|
padding: 12px 16px 14px;
|
||||||
margin: 6px 0 8px;
|
margin: 6px 0 8px;
|
||||||
}
|
}
|
||||||
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
|
.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: 12px; color: var(--neutral-text); }
|
.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-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; }
|
.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
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared';
|
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../../lib/shared';
|
||||||
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../../components/icons';
|
||||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
import ServerPicker, { serverName } from '../../../components/ServerPicker';
|
||||||
import './apikeys.css';
|
import './apikeys.css';
|
||||||
|
|
||||||
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
||||||
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
|
// один раз при создании (в БД хранится только хэш). Раздел вкладки «Настройки».
|
||||||
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
|
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
|
||||||
// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
|
// управляет мастер-ключами. По умолчанию новый ключ видит выбранный в панели сервер, набор
|
||||||
// правится в настройках ключа.
|
// правится в настройках ключа.
|
||||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
@@ -18,7 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||||
const [tools, setTools] = useState<number | 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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -77,7 +77,6 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<input
|
<input
|
||||||
className="field"
|
className="field"
|
||||||
@@ -194,11 +193,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</p>
|
</p>
|
||||||
<code className="apikey-value">{created.key}</code>
|
<code className="apikey-value">{created.key}</code>
|
||||||
<div className="qr-actions">
|
<div className="qr-actions">
|
||||||
|
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
|
||||||
<button className="btn btn--primary" onClick={async () => {
|
<button className="btn btn--primary" onClick={async () => {
|
||||||
await copyText(created.key);
|
await copyText(created.key);
|
||||||
showMsg('Ключ скопирован');
|
showMsg('Ключ скопирован');
|
||||||
}}><IcoCopy /> Скопировать</button>
|
}}><IcoCopy /> Скопировать</button>
|
||||||
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||||
.au-run { margin-left: auto; }
|
.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 {
|
.au-notice {
|
||||||
font-size: 13px; color: var(--error-text); background: var(--error-bg);
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
|
||||||
border-radius: 16px; padding: 10px 16px; margin: 0;
|
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-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: 12px; }
|
.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; }
|
.au-table td, .au-table th { white-space: nowrap; }
|
||||||
|
|
||||||
/* мобильный: таблица скрыта (.table-card), то же — карточками */
|
/* мобильный: таблица скрыта (.table-card), то же — карточками */
|
||||||
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
|
.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-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
.au-card .au-pub { max-width: 100%; }
|
.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-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||||||
.au-run { margin-left: 0; }
|
.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 { 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: 14px; font-weight: 500; }
|
.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 { 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-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-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
.au-max { width: 88px !important; }
|
.au-max { width: 88px !important; }
|
||||||
.au-select { width: auto !important; }
|
.au-select { width: auto !important; }
|
||||||
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
.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; }
|
.au-log .au-pub { max-width: 240px; }
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../../lib/shared';
|
||||||
import { IcoRefresh, IcoTrash } from '../../components/icons';
|
import { IcoRefresh, IcoTrash } from '../../../components/icons';
|
||||||
import { serverName } from '../../components/ServerPicker';
|
import { serverName } from '../../../components/ServerPicker';
|
||||||
import './audit.css';
|
import './audit.css';
|
||||||
|
|
||||||
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
|
// Раздел «Аудит» вкладки «Настройки»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
|
||||||
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
|
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
|
||||||
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
|
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
|
||||||
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
|
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
|
||||||
@@ -96,7 +96,7 @@ export default function AuditPage({ token, showMsg }: PageProps) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="au-box">
|
<section className="au-box">
|
||||||
<h4 className="au-box-title">Настройки</h4>
|
<h4 className="au-box-title">Параметры аудита</h4>
|
||||||
<label className="au-setting">
|
<label className="au-setting">
|
||||||
<span className="au-setting-text">
|
<span className="au-setting-text">
|
||||||
<span className="au-setting-name">Снимать мёртвых автоматически</span>
|
<span className="au-setting-name">Снимать мёртвых автоматически</span>
|
||||||
@@ -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 axios from 'axios';
|
||||||
|
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('Ошибка загрузки настроек');
|
||||||
|
}
|
||||||
|
axios.get('/ui/brand').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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "settings",
|
||||||
|
"label": "Настройки",
|
||||||
|
"icon": "settings",
|
||||||
|
"group": "server",
|
||||||
|
"order": 9,
|
||||||
|
"chrome": {
|
||||||
|
"restart": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// 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 } from 'react';
|
||||||
|
import { apiFetch, copyText, type PageProps } from '../../../lib/shared';
|
||||||
|
import { IcoCopy } from '../../../components/icons';
|
||||||
|
import './migrate.css';
|
||||||
|
|
||||||
|
// Раздел «Перенос» вкладки «Настройки»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
|
||||||
|
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
|
||||||
|
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
|
||||||
|
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
|
||||||
|
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
|
||||||
|
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
|
||||||
|
interface Retire {
|
||||||
|
since: number;
|
||||||
|
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
|
||||||
|
old: { exists: boolean; online: boolean };
|
||||||
|
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
|
||||||
|
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
|
||||||
|
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
|
||||||
|
}
|
||||||
|
interface MigrateInfo {
|
||||||
|
state: MoveState;
|
||||||
|
error: string | null;
|
||||||
|
target: string | null; // старая: адрес новой панели
|
||||||
|
code: { code: string; expires_in: number } | null;
|
||||||
|
export?: string | null; // null — можно, строка — почему нельзя
|
||||||
|
accept?: string | null;
|
||||||
|
retire?: Retire | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||||
|
|
||||||
|
export default function MigratePage({ token, showMsg }: PageProps) {
|
||||||
|
const [info, setInfo] = useState<MigrateInfo | null>(null);
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
|
||||||
|
catch { /* панель перезапускается — следующий опрос покажет */ }
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => { if (token) load(); }, [token, load]);
|
||||||
|
|
||||||
|
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
|
||||||
|
// перезапускается, и страница перечитывается уже с перенесённой базой.
|
||||||
|
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waiting) return;
|
||||||
|
const t = setInterval(load, 3000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [waiting, load]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (info?.state !== 'imported' || info.error) return;
|
||||||
|
const t = setTimeout(() => window.location.reload(), 8000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [info?.state, info?.error]);
|
||||||
|
|
||||||
|
const call = async (method: string, path: string, body?: object) => {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
|
||||||
|
catch (e) { setError(errText(e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
const send = async () => {
|
||||||
|
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
|
||||||
|
await load();
|
||||||
|
} catch (e) { setError(errText(e)); await load(); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
if (info.state === 'moved') return (
|
||||||
|
<section className="mv-box">
|
||||||
|
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
|
||||||
|
<p className="mv-text">
|
||||||
|
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
|
||||||
|
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
|
||||||
|
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
|
||||||
|
</p>
|
||||||
|
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (info.state === 'imported') return (
|
||||||
|
<section className="mv-box">
|
||||||
|
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
|
||||||
|
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
|
||||||
|
<p className="mv-text">
|
||||||
|
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = info.retire;
|
||||||
|
const retireBox = r && (() => {
|
||||||
|
const devicesOk = r.devices.fresh >= r.devices.total;
|
||||||
|
const nodesOk = r.nodes.every(n => n.ok);
|
||||||
|
const ready = devicesOk && nodesOk && !!r.home;
|
||||||
|
const chip = (ok: boolean, text: string, offline = false) => (
|
||||||
|
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="mv-box" key="retire">
|
||||||
|
<h4 className="mv-title">Отключение старого сервера</h4>
|
||||||
|
<p className="mv-text">
|
||||||
|
{r.old.exists
|
||||||
|
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
|
||||||
|
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
|
||||||
|
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
|
||||||
|
</p>
|
||||||
|
<div className="mv-checks">
|
||||||
|
<div className="mv-check">
|
||||||
|
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
|
||||||
|
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
|
||||||
|
</div>
|
||||||
|
{r.nodes.map(n => (
|
||||||
|
<div className="mv-check" key={n.id}>
|
||||||
|
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
|
||||||
|
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{r.unused_links > 0 && (
|
||||||
|
<div className="mv-check">
|
||||||
|
{chip(false, String(r.unused_links), true)}
|
||||||
|
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
|
||||||
|
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
|
||||||
|
<ol className="mv-steps">
|
||||||
|
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
|
||||||
|
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
|
||||||
|
<li>Удалите сервер «#0» и выключите старую машину.</li>
|
||||||
|
</ol>
|
||||||
|
{!r.old.exists && (
|
||||||
|
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const canExport = info.export === null, canAccept = info.accept === null;
|
||||||
|
const exportBox = (
|
||||||
|
<section className="mv-box" key="export">
|
||||||
|
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
|
||||||
|
{canExport ? (
|
||||||
|
<>
|
||||||
|
<p className="mv-text">
|
||||||
|
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
|
||||||
|
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
|
||||||
|
</p>
|
||||||
|
<ol className="mv-steps">
|
||||||
|
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
|
||||||
|
<li>Там в «Настройках», на вкладке «Перенос», нажмите «Получить код».</li>
|
||||||
|
<li>Вставьте код сюда и нажмите «Перенести».</li>
|
||||||
|
</ol>
|
||||||
|
{info.state === 'uncertain' ? (
|
||||||
|
<>
|
||||||
|
<p className="mv-notice">{info.error}</p>
|
||||||
|
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
|
||||||
|
Отменить перенос
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mv-row">
|
||||||
|
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
|
||||||
|
onChange={e => setCode(e.target.value)} />
|
||||||
|
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
|
||||||
|
{busy ? 'Переносим…' : 'Перенести'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : <p className="mv-text mv-muted">{info.export}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
const acceptBox = (
|
||||||
|
<section className="mv-box" key="accept">
|
||||||
|
<h4 className="mv-title">Принять панель с другого сервера</h4>
|
||||||
|
{canAccept ? (
|
||||||
|
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>
|
||||||
|
<div className="mv-row mv-row--end">
|
||||||
|
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
|
||||||
|
<IcoCopy /> Скопировать
|
||||||
|
</button>
|
||||||
|
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
|
||||||
|
</div>
|
||||||
|
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mv-text">
|
||||||
|
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
|
||||||
|
нодой #0. Логин и пароль останутся этой панели.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn--primary mv-self" disabled={busy}
|
||||||
|
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
|
||||||
|
Получить код
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : <p className="mv-text mv-muted">{info.accept}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Доступное действие — первым. */}
|
||||||
|
{retireBox}
|
||||||
|
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
|
||||||
|
{error && <p className="mv-notice">{error}</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,141 @@
|
|||||||
|
// 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; // последняя операция
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
|
||||||
|
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
|
||||||
|
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
|
||||||
|
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 с читаем статус и лог
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending && !status?.job?.running) return;
|
||||||
|
const t = setInterval(async () => {
|
||||||
|
const s = await loadStatus();
|
||||||
|
if (s && !s.job?.running) setPending(null);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [pending, status?.job?.running, 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>;
|
||||||
|
|
||||||
|
const job = status.job;
|
||||||
|
const working = !!pending || !!job?.running;
|
||||||
|
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' ? 'удаляется' : 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;
|
||||||
|
}
|
||||||
+33
-42
@@ -3,17 +3,13 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import axios from 'axios';
|
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. Сами
|
// Раздел «Сервер» вкладки «Настройки»: деление канала между устройствами (CAKE) и блокировка BitTorrent
|
||||||
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
|
// для сервера из шапки (при его смене вкладка перемонтируется). Сами настройки живут в awg-ctrl этого
|
||||||
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
|
// сервера — запрос идёт обычным прокси с X-Server-Id, так что ядро и ноды настраиваются одинаково.
|
||||||
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
export default function ServerSettings({ token, showMsg }: PageProps) {
|
||||||
token: string;
|
|
||||||
node: NodeInfo;
|
|
||||||
onClose: () => void;
|
|
||||||
showMsg: (text: string) => void;
|
|
||||||
}) {
|
|
||||||
const [st, setSt] = useState<ShapingStatus | null>(null);
|
const [st, setSt] = useState<ShapingStatus | null>(null);
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [auto, setAuto] = useState(true);
|
const [auto, setAuto] = useState(true);
|
||||||
@@ -63,12 +59,6 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [token]);
|
}, [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 downN = Number(down), upN = Number(up);
|
||||||
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
|
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 (
|
return (
|
||||||
<div className="node-scrim" onClick={onClose}>
|
<>
|
||||||
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
|
<section className="st-section ss-card">
|
||||||
<h3 className="node-modal-title">Настройки сервера: {node.name}</h3>
|
<h3 className="ss-section">Деление канала</h3>
|
||||||
<h4 className="ss-section">Деление канала</h4>
|
|
||||||
|
|
||||||
{unsupported ? (
|
{unsupported ? (
|
||||||
<p className="node-modal-hint">
|
<p className="st-hint">
|
||||||
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
|
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="node-modal-hint">
|
<p className="st-hint">
|
||||||
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
|
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
|
||||||
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
|
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
|
||||||
</p>
|
</p>
|
||||||
@@ -134,7 +123,7 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
|
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="node-modal-hint">
|
<p className="st-hint">
|
||||||
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
|
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
|
||||||
и деление перестаёт работать.
|
и деление перестаёт работать.
|
||||||
</p>
|
</p>
|
||||||
@@ -142,7 +131,7 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
<label className="ss-setting">
|
<label className="ss-setting">
|
||||||
<span className="ss-setting-text">
|
<span className="ss-setting-text">
|
||||||
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
|
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
|
||||||
<span className="node-modal-hint">
|
<span className="st-hint">
|
||||||
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
|
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
|
||||||
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
|
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
|
||||||
</span>
|
</span>
|
||||||
@@ -167,10 +156,12 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<hr className="ss-divider" />
|
</section>
|
||||||
<h4 className="ss-section">Торренты</h4>
|
|
||||||
|
<section className="st-section ss-card">
|
||||||
|
<h3 className="ss-section">Торренты</h3>
|
||||||
{tUnsupported ? (
|
{tUnsupported ? (
|
||||||
<p className="node-modal-hint">
|
<p className="st-hint">
|
||||||
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
|
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -178,12 +169,12 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
<label className="ss-setting">
|
<label className="ss-setting">
|
||||||
<span className="ss-setting-text">
|
<span className="ss-setting-text">
|
||||||
<span className="ss-setting-name">Блокировать BitTorrent</span>
|
<span className="ss-setting-name">Блокировать BitTorrent</span>
|
||||||
<span className="node-modal-hint">
|
<span className="st-hint">
|
||||||
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
|
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
|
||||||
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
|
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
|
||||||
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
|
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
|
||||||
</span>
|
</span>
|
||||||
<span className="node-modal-hint">
|
<span className="st-hint">
|
||||||
{enabled
|
{enabled
|
||||||
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
|
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
|
||||||
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
|
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
|
||||||
@@ -199,14 +190,14 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
</span>
|
</span>
|
||||||
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
|
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
|
||||||
{tst.active && !tst.tcp && (
|
{tst.active && !tst.tcp && (
|
||||||
<span className="node-modal-hint">
|
<span className="st-hint">
|
||||||
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
|
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{tst.error && <span className="node-modal-hint">{tst.error}</span>}
|
{tst.error && <span className="st-hint">{tst.error}</span>}
|
||||||
{tst.seen.length > 0 && (
|
{tst.seen.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="node-modal-hint">Пытались качать за последние 10 минут:</span>
|
<span className="st-hint">Пытались качать за последние 10 минут:</span>
|
||||||
<span className="ss-seen">
|
<span className="ss-seen">
|
||||||
{tst.seen.map(p => (
|
{tst.seen.map(p => (
|
||||||
<span key={p.ip} className="chip chip--error">
|
<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">
|
{error && <p className="login-error">{error}</p>}
|
||||||
{!(unsupported && tUnsupported) && (
|
|
||||||
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
|
{!(unsupported && tUnsupported) && (
|
||||||
Сохранить
|
<div className="ss-actions">
|
||||||
</button>
|
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
|
||||||
)}
|
Сохранить
|
||||||
<button className="btn btn--outline" onClick={onClose}>Закрыть</button>
|
</button>
|
||||||
</div>
|
</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; }
|
||||||
@@ -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; }
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import QRCode from 'qrcode';
|
|
||||||
import {
|
import {
|
||||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
|
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
|
||||||
type User, type PageProps, type AwgGen,
|
type User, type PageProps, type AwgGen,
|
||||||
} from '../../lib/shared';
|
} from '../../lib/shared';
|
||||||
|
import { qrDataUrl } from '../../lib/qr';
|
||||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||||
import PriorityChip from '../../components/PriorityChip';
|
import PriorityChip from '../../components/PriorityChip';
|
||||||
import TorrentChip from '../../components/TorrentChip';
|
import TorrentChip from '../../components/TorrentChip';
|
||||||
@@ -109,12 +109,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
const vpnKey = u.vpn_key ?? '';
|
const vpnKey = u.vpn_key ?? '';
|
||||||
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
|
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
|
||||||
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
|
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
|
||||||
const dataUrl = await QRCode.toDataURL(vpnKey, {
|
setQrModal({ name, dataUrl: qrDataUrl(vpnKey, 'L'), vpnKey });
|
||||||
width: 560,
|
|
||||||
margin: 2,
|
|
||||||
errorCorrectionLevel: 'L',
|
|
||||||
});
|
|
||||||
setQrModal({ name, dataUrl, vpnKey });
|
|
||||||
} catch {
|
} catch {
|
||||||
showMsg('Не удалось сгенерировать QR-код');
|
showMsg('Не удалось сгенерировать QR-код');
|
||||||
}
|
}
|
||||||
@@ -297,6 +292,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
|
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
|
||||||
<div className="qr-actions">
|
<div className="qr-actions">
|
||||||
|
<button className="btn btn--text" onClick={() => setQrModal(null)}>Закрыть</button>
|
||||||
<button className="btn btn--tonal" onClick={async () => {
|
<button className="btn btn--tonal" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const conf = await vpnKeyToConf(qrModal.vpnKey);
|
const conf = await vpnKeyToConf(qrModal.vpnKey);
|
||||||
@@ -305,7 +301,6 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
showMsg('Конфиг скачан');
|
showMsg('Конфиг скачан');
|
||||||
} catch { showMsg('Не удалось декодировать ключ'); }
|
} catch { showMsg('Не удалось декодировать ключ'); }
|
||||||
}}>Скачать .conf</button>
|
}}>Скачать .conf</button>
|
||||||
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ LICENSE file in the root directory of this source tree.
|
|||||||
|
|
||||||
.user-cards-list { display: none; flex-direction: column; gap: 12px; }
|
.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 {
|
.empty-cards {
|
||||||
font-size: 14px;
|
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 0;
|
padding: 48px 0;
|
||||||
@@ -18,7 +18,7 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.user-card {
|
.user-card {
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: var(--r-card);
|
border-radius: var(--shape-extra-large);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -36,10 +36,10 @@ LICENSE file in the root directory of this source tree.
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
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 {
|
.user-card-name {
|
||||||
font-size: 16px;
|
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
|
||||||
font-weight: 500;
|
font-weight: var(--weight-medium);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -52,20 +52,23 @@ LICENSE file in the root directory of this source tree.
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--neutral-text);
|
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; }
|
.user-card-metrics { display: flex; gap: 8px; }
|
||||||
.metric-block {
|
.metric-block {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--surface-container);
|
background: var(--surface-container);
|
||||||
border-radius: var(--r-table);
|
border-radius: var(--shape-large);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.metric-label { font-size: 12px; color: var(--neutral-text); }
|
.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: 18px; font-weight: 500; color: var(--on-surface); }
|
.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 { display: flex; gap: 8px; }
|
||||||
.user-card-actions .btn--outline { flex: 1; }
|
.user-card-actions .btn--outline { flex: 1; }
|
||||||
@@ -73,9 +76,14 @@ LICENSE file in the root directory of this source tree.
|
|||||||
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
|
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
|
||||||
.user-gen { margin-left: 8px; vertical-align: middle; }
|
.user-gen { margin-left: 8px; vertical-align: middle; }
|
||||||
|
|
||||||
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
/* compact и medium (< 840px): таблица скрывается (.table-card в App.css), показываются карточки —
|
||||||
@media (max-width: 640px) {
|
в одну колонку на телефоне, сеткой на планшете. */
|
||||||
.user-cards-list { display: flex; }
|
@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 на мобильной растягивается на всю ширину — метке это не нужно. */
|
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
|
||||||
.user-gen { width: auto; }
|
.user-gen { width: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+11
-5
@@ -4,15 +4,21 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const api = `http://localhost:${process.env.MOCK_PORT || 8080}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
|
||||||
|
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
|
||||||
|
// (main.tsx).
|
||||||
|
base: './',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8080',
|
'/api': api,
|
||||||
'/ui': 'http://localhost:8080',
|
'/ui': api,
|
||||||
'/health': 'http://localhost:8080',
|
'/health': api,
|
||||||
'/login': 'http://localhost:8080',
|
'/login': api,
|
||||||
'/logout': 'http://localhost:8080',
|
'/logout': api,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора.
|
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/maeneko/forgetting/internal/agent"
|
"github.com/maeneko/forgetting/internal/agent"
|
||||||
"github.com/maeneko/forgetting/internal/cli"
|
"github.com/maeneko/forgetting/internal/cli"
|
||||||
"github.com/maeneko/forgetting/internal/ctrl"
|
"github.com/maeneko/forgetting/internal/ctrl"
|
||||||
|
"github.com/maeneko/forgetting/internal/relay"
|
||||||
"github.com/maeneko/forgetting/internal/ui"
|
"github.com/maeneko/forgetting/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +61,13 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
a.Run(ctx)
|
a.Run(ctx)
|
||||||
|
case "relay":
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := relay.Main(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
cli.Main(os.Args[1:])
|
cli.Main(os.Args[1:])
|
||||||
}
|
}
|
||||||
|
|||||||
+1073
-510
File diff suppressed because it is too large
Load Diff
+304
-21
@@ -1,7 +1,9 @@
|
|||||||
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
|
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
|
||||||
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
|
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
|
||||||
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
|
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
|
||||||
// awg-ctrl остаётся закрытым от сети. Протокол и формат join-строки — docs/node-protocol.md.
|
// awg-ctrl остаётся закрытым от сети. Если на ноде стоит Prometheus (`awg-ctrl monitor install`),
|
||||||
|
// агент отдаёт ему метрики панели, взятые у core, и пропускает к нему запросы вкладки «Монитор».
|
||||||
|
// Протокол и формат join-строки — docs/node-protocol.md.
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,13 +12,16 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -29,6 +34,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/envfile"
|
||||||
"github.com/maeneko/forgetting/internal/iauth"
|
"github.com/maeneko/forgetting/internal/iauth"
|
||||||
"github.com/maeneko/forgetting/internal/jsjson"
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
)
|
)
|
||||||
@@ -42,11 +48,14 @@ const (
|
|||||||
ctrlTimeout = 110 * time.Second
|
ctrlTimeout = 110 * time.Second
|
||||||
maxBackoff = 60 * time.Second
|
maxBackoff = 60 * time.Second
|
||||||
closeAuthFailed = 4003
|
closeAuthFailed = 4003
|
||||||
|
promTimeout = 8 * time.Second // как у панели к своему Prometheus
|
||||||
|
metricsTimeout = 9 * time.Second // меньше scrape_timeout Prometheus (10 с по умолчанию)
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CoreHost string
|
CoreHost string
|
||||||
CorePort int
|
CorePort int
|
||||||
|
CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым
|
||||||
CorePin string // SHA-256 SPKI сертификата core, base64url
|
CorePin string // SHA-256 SPKI сертификата core, base64url
|
||||||
NodeID int
|
NodeID int
|
||||||
Ctrl string // http://127.0.0.1:PORT
|
Ctrl string // http://127.0.0.1:PORT
|
||||||
@@ -55,6 +64,9 @@ type Config struct {
|
|||||||
CLIEnvFile string
|
CLIEnvFile string
|
||||||
JoinSecret string // одноразовый, живёт до первого успешного подключения
|
JoinSecret string // одноразовый, живёт до первого успешного подключения
|
||||||
Version string
|
Version string
|
||||||
|
PromURL string // Prometheus этой машины: к нему агент пропускает запросы панели (/prom/…)
|
||||||
|
MetricsPort int // 127.0.0.1:порт, где Prometheus ноды скрейпит метрики панели; 0 — мониторинга на ноде нет
|
||||||
|
MonitorHash string // хэш токена этого Prometheus (его пишет `awg-ctrl monitor install`)
|
||||||
Logf, Errorf func(format string, a ...any)
|
Logf, Errorf func(format string, a ...any)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +89,8 @@ func envFromFile(file, key string) string {
|
|||||||
func ConfigFromEnv() (Config, error) {
|
func ConfigFromEnv() (Config, error) {
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd()
|
||||||
atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n }
|
atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n }
|
||||||
|
// Нода №0 — сервер, с которого переехала панель: 0 здесь — значение, а не «не задано».
|
||||||
|
nodeID, idErr := strconv.Atoi(strings.TrimSpace(os.Getenv("NODE_ID")))
|
||||||
or := func(k, def string) string {
|
or := func(k, def string) string {
|
||||||
if v, ok := os.LookupEnv(k); ok {
|
if v, ok := os.LookupEnv(k); ok {
|
||||||
return v
|
return v
|
||||||
@@ -84,21 +98,25 @@ func ConfigFromEnv() (Config, error) {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
c := Config{
|
c := Config{
|
||||||
CoreHost: os.Getenv("CORE_HOST"),
|
CoreHost: os.Getenv("CORE_HOST"),
|
||||||
CorePort: atoi("CORE_PORT"),
|
CorePort: atoi("CORE_PORT"),
|
||||||
CorePin: os.Getenv("CORE_PIN"),
|
CorePin: os.Getenv("CORE_PIN"),
|
||||||
NodeID: atoi("NODE_ID"),
|
CoreEndpoint: os.Getenv("CORE_ENDPOINT"),
|
||||||
Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
|
NodeID: nodeID,
|
||||||
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
|
Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
|
||||||
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
|
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
|
||||||
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
|
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
|
||||||
JoinSecret: os.Getenv("JOIN_SECRET"),
|
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
|
||||||
Version: os.Getenv("VERSION"),
|
JoinSecret: os.Getenv("JOIN_SECRET"),
|
||||||
|
Version: os.Getenv("VERSION"),
|
||||||
|
PromURL: or("PROM_URL", "http://127.0.0.1:9090"),
|
||||||
|
MetricsPort: atoi("AGENT_METRICS_PORT"),
|
||||||
|
MonitorHash: or("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
|
||||||
}
|
}
|
||||||
if c.Version == "" {
|
if c.Version == "" {
|
||||||
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
|
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
|
||||||
}
|
}
|
||||||
if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || c.NodeID == 0 {
|
if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || idErr != nil || c.NodeID < 0 {
|
||||||
return c, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`")
|
return c, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`")
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -112,8 +130,21 @@ type Agent struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
joinSecret string
|
joinSecret string
|
||||||
|
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
|
||||||
|
cur int
|
||||||
|
link *coreLink // соединение после ready; nil — core сейчас недоступен
|
||||||
|
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
prom *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// coreLink — запросы ноды к core (пока один: метрики панели для Prometheus ноды).
|
||||||
|
type coreLink struct {
|
||||||
|
send func(v any)
|
||||||
|
done <-chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
seq int64
|
||||||
|
pending map[int64]chan reply
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
|
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
|
||||||
@@ -151,7 +182,9 @@ func New(c Config) (*Agent, error) {
|
|||||||
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
|
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
|
||||||
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
|
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
|
||||||
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
|
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
|
||||||
|
prom: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: promTimeout},
|
||||||
}
|
}
|
||||||
|
a.setAddrs(c.CoreEndpoint)
|
||||||
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
|
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
|
||||||
a.internalKey = ik
|
a.internalKey = ik
|
||||||
} else {
|
} else {
|
||||||
@@ -165,6 +198,26 @@ func authMsg(nonce string, nodeID int) []byte {
|
|||||||
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
|
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 ────────────────────────────────────────────────
|
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
|
||||||
|
|
||||||
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
|
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
|
||||||
@@ -204,11 +257,13 @@ func errReply(status int, msg string) reply {
|
|||||||
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
|
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// execute выполняет запрос core на локальном awg-ctrl. Ответ возвращается как его разобрал
|
// execute выполняет запрос core на локальном awg-ctrl (или, для /prom/…, на Prometheus).
|
||||||
// бы axios: JSON — значением, иначе строкой (пустое тело — "").
|
|
||||||
func (a *Agent) execute(m jsjson.Object) reply {
|
func (a *Agent) execute(m jsjson.Object) reply {
|
||||||
method, _ := m.Get2("method").(string)
|
method, _ := m.Get2("method").(string)
|
||||||
method = strings.ToUpper(method)
|
method = strings.ToUpper(method)
|
||||||
|
if s, _ := m.Get2("path").(string); strings.HasPrefix(s, "/prom/") {
|
||||||
|
return a.execProm(method, s)
|
||||||
|
}
|
||||||
p, ok := pathAllowed(m.Get2("path"))
|
p, ok := pathAllowed(m.Get2("path"))
|
||||||
if !methods[method] || !ok {
|
if !methods[method] || !ok {
|
||||||
return errReply(400, "Запрос не разрешён агентом")
|
return errReply(400, "Запрос не разрешён агентом")
|
||||||
@@ -234,14 +289,19 @@ func (a *Agent) execute(m jsjson.Object) reply {
|
|||||||
if a.internalKey != nil {
|
if a.internalKey != nil {
|
||||||
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
|
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
|
||||||
}
|
}
|
||||||
resp, err := a.http.Do(req)
|
return do(a.http, req, "awg-ctrl недоступен")
|
||||||
|
}
|
||||||
|
|
||||||
|
// do — ответ как его разобрал бы axios: JSON — значением, иначе строкой (пустое тело — "").
|
||||||
|
func do(c *http.Client, req *http.Request, down string) reply {
|
||||||
|
resp, err := c.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errReply(502, "awg-ctrl недоступен")
|
return errReply(502, down)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
raw, err := io.ReadAll(resp.Body)
|
raw, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errReply(502, "awg-ctrl недоступен")
|
return errReply(502, down)
|
||||||
}
|
}
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return reply{resp.StatusCode, ""}
|
return reply{resp.StatusCode, ""}
|
||||||
@@ -252,6 +312,40 @@ func (a *Agent) execute(m jsjson.Object) reply {
|
|||||||
return reply{resp.StatusCode, string(raw)}
|
return reply{resp.StatusCode, string(raw)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promPaths — всё, что панель читает у Prometheus: готовность и фиксированные запросы вкладки.
|
||||||
|
var promPaths = map[string]bool{"/prom/-/ready": true, "/prom/api/v1/query": true, "/prom/api/v1/query_range": true}
|
||||||
|
|
||||||
|
// promAllowed — запрос панели к Prometheus этой машины: только GET и только пути из promPaths.
|
||||||
|
func promAllowed(method string, p any) (string, bool) {
|
||||||
|
s, ok := p.(string)
|
||||||
|
if method != "GET" || !ok || len(s) > 8192 || !rePath.MatchString(s) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
pathname, query, _ := strings.Cut(s, "?")
|
||||||
|
if !promPaths[pathname] || strings.Contains(query, "#") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(s, "/prom"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// execProm — Prometheus ноды для вкладки «Монитор» панели. Пропускаем, только если он
|
||||||
|
// скрейпит панель через этого агента (AGENT_METRICS_PORT): чужой Prometheus на машине
|
||||||
|
// панели не отдаём, да и метрик панели в нём нет.
|
||||||
|
func (a *Agent) execProm(method string, p any) reply {
|
||||||
|
if a.c.MetricsPort == 0 {
|
||||||
|
return errReply(404, "Prometheus на этой ноде не подключён к панели")
|
||||||
|
}
|
||||||
|
rest, ok := promAllowed(method, p)
|
||||||
|
if !ok {
|
||||||
|
return errReply(400, "Запрос не разрешён агентом")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", strings.TrimSuffix(a.c.PromURL, "/")+rest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errReply(400, "Запрос не разрешён агентом")
|
||||||
|
}
|
||||||
|
return do(a.prom, req, "Prometheus не отвечает")
|
||||||
|
}
|
||||||
|
|
||||||
// ── Соединение с core ──────────────────────────────────────────────────────
|
// ── Соединение с core ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func spkiPin(rawCert []byte) (string, error) {
|
func spkiPin(rawCert []byte) (string, error) {
|
||||||
@@ -306,6 +400,71 @@ func (a *Agent) forgetJoinSecret() {
|
|||||||
os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600)
|
os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setAddrs — сначала адрес, который сообщил core, потом адрес из join: как endpoints у клиента
|
||||||
|
// sen://. Чужой сервер по нему не подменить — доверие даёт pin, а не адрес.
|
||||||
|
func (a *Agent) setAddrs(learned string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.addrs = nil
|
||||||
|
for _, s := range []string{learned, hostPort(a.c.CoreHost, a.c.CorePort)} {
|
||||||
|
if s != "" && (len(a.addrs) == 0 || a.addrs[0] != s) {
|
||||||
|
a.addrs = append(a.addrs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cur = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) addr() string {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.addrs[a.cur%len(a.addrs)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// next — не дозвонились (или core не принял): следующий адрес.
|
||||||
|
func (a *Agent) next() {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.cur = (a.cur + 1) % len(a.addrs)
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// learnCore — core сообщил свой адрес (панель переехала, старый сервер скоро выключат):
|
||||||
|
// запоминаем в cli.env, чтобы после перезапуска звонить сразу туда. false — не запомнили.
|
||||||
|
func (a *Agent) learnCore(addr string) bool {
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if p, perr := strconv.Atoi(port); err != nil || perr != nil || p <= 0 || p > 65535 || !reCoreHost.MatchString(host) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr = hostPort(host, mustAtoi(port))
|
||||||
|
if addr == a.c.CoreEndpoint {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a.c.CLIEnvFile == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := envfile.Set(a.c.CLIEnvFile, [][2]string{{"CORE_ENDPOINT", addr}}); err != nil {
|
||||||
|
a.c.Errorf("agent: адрес core не сохранён: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.c.CoreEndpoint = addr
|
||||||
|
a.mu.Lock()
|
||||||
|
cur := a.addrs[a.cur%len(a.addrs)]
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.setAddrs(addr)
|
||||||
|
a.mu.Lock()
|
||||||
|
for i, s := range a.addrs { // текущее соединение не рвём: адрес пригодится при следующем
|
||||||
|
if s == cur {
|
||||||
|
a.cur = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.c.Logf("agent: core теперь доступен и по %s", addr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var reCoreHost = regexp.MustCompile(`^[A-Za-z0-9.:-]{1,253}$`)
|
||||||
|
|
||||||
|
func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||||
|
|
||||||
func (a *Agent) secret() string {
|
func (a *Agent) secret() string {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
@@ -337,13 +496,20 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
|||||||
|
|
||||||
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
|
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
|
||||||
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
|
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
|
||||||
ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", hostPort(a.c.CoreHost, a.c.CorePort)), &websocket.DialOptions{
|
ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
|
||||||
HTTPClient: &http.Client{Transport: tr},
|
HTTPClient: &http.Client{Transport: tr},
|
||||||
|
HTTPHeader: a.preauthHeader(),
|
||||||
CompressionMode: websocket.CompressionDisabled,
|
CompressionMode: websocket.CompressionDisabled,
|
||||||
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
|
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
|
||||||
})
|
})
|
||||||
dcancel()
|
dcancel()
|
||||||
if err != nil {
|
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) {
|
if errors.Is(err, errPin) {
|
||||||
a.c.Errorf("agent: %v", errPin)
|
a.c.Errorf("agent: %v", errPin)
|
||||||
} else {
|
} else {
|
||||||
@@ -352,6 +518,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer ws.CloseNow()
|
defer ws.CloseNow()
|
||||||
|
defer a.setLink(nil)
|
||||||
ws.SetReadLimit(maxPayload)
|
ws.SetReadLimit(maxPayload)
|
||||||
|
|
||||||
send := func(v any) {
|
send := func(v any) {
|
||||||
@@ -371,6 +538,9 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
|||||||
if code == closeAuthFailed {
|
if code == closeAuthFailed {
|
||||||
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
|
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
|
||||||
}
|
}
|
||||||
|
if code == 4026 {
|
||||||
|
a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err)
|
||||||
|
}
|
||||||
return code == closeAuthFailed
|
return code == closeAuthFailed
|
||||||
}
|
}
|
||||||
v, err := jsjson.Decode(raw)
|
v, err := jsjson.Decode(raw)
|
||||||
@@ -398,6 +568,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
|||||||
case t == "ready":
|
case t == "ready":
|
||||||
ready = true
|
ready = true
|
||||||
armDead()
|
armDead()
|
||||||
|
a.setLink(&coreLink{send: send, done: ctx.Done(), pending: map[int64]chan reply{}})
|
||||||
if onReady != nil {
|
if onReady != nil {
|
||||||
onReady()
|
onReady()
|
||||||
}
|
}
|
||||||
@@ -405,7 +576,14 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
|||||||
a.forgetJoinSecret()
|
a.forgetJoinSecret()
|
||||||
a.c.Logf("agent: нода привязана к core")
|
a.c.Logf("agent: нода привязана к core")
|
||||||
}
|
}
|
||||||
a.c.Logf("agent: подключено к core")
|
a.c.Logf("agent: подключено к core (%s)", a.addr())
|
||||||
|
// Подтверждение — только когда адрес уже на диске: по нему панель решает, что
|
||||||
|
// старый сервер можно выключать.
|
||||||
|
if core, ok := m.Get2("core").(string); ok && a.learnCore(core) {
|
||||||
|
send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}})
|
||||||
|
}
|
||||||
|
case t == "metrics_res" && ready:
|
||||||
|
a.deliver(m)
|
||||||
case t == "req" && ready:
|
case t == "req" && ready:
|
||||||
id, ok := m.Get2("id").(float64)
|
id, ok := m.Get2("id").(float64)
|
||||||
if !ok || id != float64(int64(id)) {
|
if !ok || id != float64(int64(id)) {
|
||||||
@@ -420,6 +598,104 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Метрики панели для Prometheus ноды ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *Agent) setLink(l *coreLink) {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.link = l
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver — ответ core на запрос метрик.
|
||||||
|
func (a *Agent) deliver(m jsjson.Object) {
|
||||||
|
id, ok := m.Get2("id").(float64)
|
||||||
|
a.mu.Lock()
|
||||||
|
l := a.link
|
||||||
|
a.mu.Unlock()
|
||||||
|
if !ok || l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
ch := l.pending[int64(id)]
|
||||||
|
delete(l.pending, int64(id))
|
||||||
|
l.mu.Unlock()
|
||||||
|
if ch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, _ := m.Get2("status").(float64)
|
||||||
|
text, _ := m.Get2("text").(string)
|
||||||
|
ch <- reply{int(st), text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// coreMetrics — экспозиция /metrics панели, как её отдал бы сам core.
|
||||||
|
func (a *Agent) coreMetrics(ctx context.Context) reply {
|
||||||
|
a.mu.Lock()
|
||||||
|
l := a.link
|
||||||
|
a.mu.Unlock()
|
||||||
|
if l == nil {
|
||||||
|
return reply{503, "core недоступен\n"}
|
||||||
|
}
|
||||||
|
ch := make(chan reply, 1)
|
||||||
|
l.mu.Lock()
|
||||||
|
l.seq++
|
||||||
|
id := l.seq
|
||||||
|
l.pending[id] = ch
|
||||||
|
l.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
l.mu.Lock()
|
||||||
|
delete(l.pending, id)
|
||||||
|
l.mu.Unlock()
|
||||||
|
}()
|
||||||
|
l.send(jsjson.Object{{Key: "t", Val: "metrics"}, {Key: "id", Val: id}})
|
||||||
|
select {
|
||||||
|
case r := <-ch:
|
||||||
|
return r
|
||||||
|
case <-l.done:
|
||||||
|
return reply{503, "core недоступен\n"}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return reply{504, "core не ответил\n"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenOK — тот же токен, что встроенный Prometheus предъявил бы самой панели: хэш в файле,
|
||||||
|
// перечитываем на каждый скрейп (раз в 15 с), чтобы переустановка не требовала перезапуска.
|
||||||
|
func (a *Agent) tokenOK(r *http.Request) bool {
|
||||||
|
given, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
raw, err := os.ReadFile(a.c.MonitorHash)
|
||||||
|
if !ok || !strings.HasPrefix(given, "awgm_") || err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := hex.DecodeString(strings.TrimSpace(string(raw)))
|
||||||
|
sum := sha256.Sum256([]byte(given))
|
||||||
|
return err == nil && len(want) == len(sum) && subtle.ConstantTimeCompare(sum[:], want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveMetrics — 127.0.0.1:AGENT_METRICS_PORT/metrics для Prometheus этой ноды: метрики всех
|
||||||
|
// серверов панели, полученные у core по уже открытому WSS. Наружу порт не открывается.
|
||||||
|
func (a *Agent) serveMetrics(ctx context.Context) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.tokenOK(r) {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
http.Error(w, "unauthorized", 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(r.Context(), metricsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
res := a.coreMetrics(cctx)
|
||||||
|
text, _ := res.data.(string)
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
w.WriteHeader(res.status)
|
||||||
|
io.WriteString(w, text)
|
||||||
|
})
|
||||||
|
srv := &http.Server{Addr: "127.0.0.1:" + strconv.Itoa(a.c.MetricsPort), Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
go func() { <-ctx.Done(); srv.Close() }()
|
||||||
|
a.c.Logf("agent: метрики панели для Prometheus — http://%s/metrics", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
a.c.Errorf("agent: метрики для Prometheus не подняты: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func hostPort(host string, port int) string {
|
func hostPort(host string, port int) string {
|
||||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||||
host = "[" + host + "]"
|
host = "[" + host + "]"
|
||||||
@@ -429,10 +705,17 @@ func hostPort(host string, port int) string {
|
|||||||
|
|
||||||
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
|
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
|
||||||
func (a *Agent) Run(ctx context.Context) error {
|
func (a *Agent) Run(ctx context.Context) error {
|
||||||
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, hostPort(a.c.CoreHost, a.c.CorePort))
|
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
|
||||||
|
if a.c.MetricsPort > 0 {
|
||||||
|
go a.serveMetrics(ctx)
|
||||||
|
}
|
||||||
backoff := time.Second
|
backoff := time.Second
|
||||||
for {
|
for {
|
||||||
authFailed := a.connectOnce(ctx, func() { backoff = time.Second })
|
ready := false
|
||||||
|
authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true })
|
||||||
|
if !ready {
|
||||||
|
a.next()
|
||||||
|
}
|
||||||
wait := backoff
|
wait := backoff
|
||||||
if authFailed {
|
if authFailed {
|
||||||
wait = maxBackoff
|
wait = maxBackoff
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -268,3 +271,201 @@ func TestNodeKeyPersists(t *testing.T) {
|
|||||||
t.Fatalf("node.key mode %v", st.Mode().Perm())
|
t.Fatalf("node.key mode %v", st.Mode().Perm())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Нода #0 — сервер, с которого переехала панель: 0 — значение, а не «не задан».
|
||||||
|
func TestConfigNodeZero(t *testing.T) {
|
||||||
|
t.Setenv("CORE_HOST", "203.0.113.7")
|
||||||
|
t.Setenv("CORE_PORT", "40500")
|
||||||
|
t.Setenv("CORE_PIN", "pin")
|
||||||
|
for id, ok := range map[string]bool{"0": true, "3": true, "": false, "-1": false, "x": false} {
|
||||||
|
t.Setenv("NODE_ID", id)
|
||||||
|
c, err := ConfigFromEnv()
|
||||||
|
if (err == nil) != ok {
|
||||||
|
t.Errorf("NODE_ID=%q: %v", id, err)
|
||||||
|
}
|
||||||
|
if ok && strconv.Itoa(c.NodeID) != id {
|
||||||
|
t.Errorf("NODE_ID=%q → %d", id, c.NodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Панель переехала: core сообщает свой адрес в ready, агент записывает его в cli.env,
|
||||||
|
// подтверждает (core_ok) и с тех пор звонит туда первым, а адрес из join — запасной.
|
||||||
|
func TestLearnsCoreAddress(t *testing.T) {
|
||||||
|
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
|
||||||
|
read()
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}, {Key: "core", Val: "198.51.100.9:40500"}})
|
||||||
|
read()
|
||||||
|
ws.Close(websocket.StatusNormalClosure, "")
|
||||||
|
})
|
||||||
|
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
|
||||||
|
host, port := c.hostPort()
|
||||||
|
join := hostPort(host, port)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
a.connectOnce(ctx, nil)
|
||||||
|
|
||||||
|
<-c.frames // auth
|
||||||
|
if ok := <-c.frames; string(jsjson.MustMarshal(ok)) != `{"t":"core_ok","core":"198.51.100.9:40500"}` {
|
||||||
|
t.Errorf("подтверждение: %s", jsjson.MustMarshal(ok))
|
||||||
|
}
|
||||||
|
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "CORE_ENDPOINT=198.51.100.9:40500") {
|
||||||
|
t.Errorf("cli.env: %q", raw)
|
||||||
|
}
|
||||||
|
if strings.Join(a.addrs, " ") != "198.51.100.9:40500 "+join || a.addr() != join {
|
||||||
|
t.Errorf("адреса %v, текущий %s", a.addrs, a.addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadCoreAddressIgnored(t *testing.T) {
|
||||||
|
a := &Agent{c: Config{CoreHost: "203.0.113.1", CorePort: 40500, CLIEnvFile: filepath.Join(t.TempDir(), "cli.env"), Logf: t.Logf, Errorf: t.Logf}}
|
||||||
|
a.setAddrs("")
|
||||||
|
for _, bad := range []string{"", "x", "host:0", "host:99999", "a b:1", "host:1/x", "[::1]:x"} {
|
||||||
|
if a.learnCore(bad) {
|
||||||
|
t.Errorf("принят адрес %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !a.learnCore("[2001:db8::1]:443") || a.addrs[0] != "[2001:db8::1]:443" {
|
||||||
|
t.Errorf("IPv6: %v", a.addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записанный адрес не отвечает (старый сервер выключен или новый ещё не открыт) — следующая
|
||||||
|
// попытка идёт на адрес из join.
|
||||||
|
func TestFallsBackToJoinAddress(t *testing.T) {
|
||||||
|
connected := make(chan struct{}, 1)
|
||||||
|
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
|
||||||
|
read()
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
|
||||||
|
connected <- struct{}{}
|
||||||
|
<-ctx.Done()
|
||||||
|
})
|
||||||
|
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
|
||||||
|
a.c.CoreEndpoint = "127.0.0.1:1"
|
||||||
|
a.setAddrs(a.c.CoreEndpoint)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go a.Run(ctx)
|
||||||
|
select {
|
||||||
|
case <-connected:
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("агент не перешёл на адрес из join")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromAllowed(t *testing.T) {
|
||||||
|
for p, want := range map[string]string{
|
||||||
|
"/prom/-/ready": "/-/ready",
|
||||||
|
"/prom/api/v1/query?query=up&time=1": "/api/v1/query?query=up&time=1",
|
||||||
|
"/prom/api/v1/query_range?query=up": "/api/v1/query_range?query=up",
|
||||||
|
"/prom/api/v1/admin/tsdb/delete_series": "",
|
||||||
|
"/prom/api/v1/query/../admin": "",
|
||||||
|
"/prom//api/v1/query": "",
|
||||||
|
"/prom/api/v1/query?query=up#x": "",
|
||||||
|
"/prom/api/v1/query?query=up\nHost: x": "",
|
||||||
|
"/prom/api/v1/labels": "",
|
||||||
|
} {
|
||||||
|
got, ok := promAllowed("GET", p)
|
||||||
|
if want == "" && ok || want != "" && got != want {
|
||||||
|
t.Errorf("promAllowed(%q) = %q, %v", p, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := promAllowed("POST", "/prom/api/v1/query"); ok {
|
||||||
|
t.Error("POST к Prometheus пропущен")
|
||||||
|
}
|
||||||
|
a := &Agent{c: Config{PromURL: "http://127.0.0.1:1"}}
|
||||||
|
if r := a.execProm("GET", "/prom/-/ready"); r.status != 404 {
|
||||||
|
t.Errorf("без AGENT_METRICS_PORT: %d", r.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prometheus на ноде: запросы панели доходят до него, а его скрейп получает метрики core.
|
||||||
|
func TestPrometheusOnNode(t *testing.T) {
|
||||||
|
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/query":
|
||||||
|
io.WriteString(w, `{"status":"success","q":"`+r.URL.Query().Get("query")+`"}`)
|
||||||
|
case "/-/ready":
|
||||||
|
io.WriteString(w, "Prometheus Server is Ready.\n")
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer prom.Close()
|
||||||
|
|
||||||
|
results := make(chan jsjson.Object, 8)
|
||||||
|
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
|
||||||
|
if read() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/query?query=up"}})
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/admin/tsdb/snapshot"}})
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/-/ready"}})
|
||||||
|
for {
|
||||||
|
m := read()
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch m.Get2("t") {
|
||||||
|
case "res":
|
||||||
|
results <- m
|
||||||
|
case "metrics":
|
||||||
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: m.Get2("id")}, {Key: "status", Val: 200}, {Key: "text", Val: "awg_up 1\n"}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
l.Close()
|
||||||
|
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
|
||||||
|
a.c.PromURL, a.c.MetricsPort = prom.URL, port
|
||||||
|
a.c.MonitorHash = filepath.Join(t.TempDir(), "monitor_token.sha256")
|
||||||
|
sum := sha256.Sum256([]byte("awgm_good"))
|
||||||
|
os.WriteFile(a.c.MonitorHash, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go a.Run(ctx)
|
||||||
|
|
||||||
|
byID := map[float64]string{}
|
||||||
|
for range 3 {
|
||||||
|
select {
|
||||||
|
case r := <-results:
|
||||||
|
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("нет ответов агента")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, want := range map[float64]string{
|
||||||
|
1: `{"t":"res","id":1,"status":200,"data":{"status":"success","q":"up"}}`,
|
||||||
|
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
|
||||||
|
3: `{"t":"res","id":3,"status":200,"data":"Prometheus Server is Ready.\n"}`,
|
||||||
|
} {
|
||||||
|
if byID[id] != want {
|
||||||
|
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrape := func(tok string) (int, string) {
|
||||||
|
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(port)+"/metrics", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
}
|
||||||
|
if st, _ := scrape("awgm_bad"); st != 401 {
|
||||||
|
t.Errorf("чужой токен: %d", st)
|
||||||
|
}
|
||||||
|
if st, body := scrape("awgm_good"); st != 200 || body != "awg_up 1\n" {
|
||||||
|
t.Errorf("скрейп: %d %q", st, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+106
-19
@@ -17,6 +17,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/envfile"
|
||||||
"github.com/maeneko/forgetting/internal/jsjson"
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,6 +165,9 @@ func New() *CLI {
|
|||||||
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
|
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
|
||||||
env: [][2]string{
|
env: [][2]string{
|
||||||
{"PORT", env("UI_PORT", "8080")},
|
{"PORT", env("UI_PORT", "8080")},
|
||||||
|
// Секретный путь панели; _MOVED — путь панели, переехавшей сюда (принимаются оба).
|
||||||
|
{"UI_PREFIX", env("UI_PREFIX", "")},
|
||||||
|
{"UI_PREFIX_MOVED", env("UI_PREFIX_MOVED", "")},
|
||||||
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
||||||
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
|
{"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},
|
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
|
||||||
@@ -181,6 +185,16 @@ func New() *CLI {
|
|||||||
{"SUB_HOST", env("SUB_HOST", "")},
|
{"SUB_HOST", env("SUB_HOST", "")},
|
||||||
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
||||||
{"NODE_PORT", env("NODE_PORT", "")},
|
{"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.
|
||||||
|
{"TRUST_PROXY", env("TRUST_PROXY", "")},
|
||||||
|
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
|
||||||
|
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
|
||||||
|
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
|
||||||
|
{"CLI_ENV_FILE", cliEnv},
|
||||||
// Часовой аудит пиров на всех серверах — только у core.
|
// Часовой аудит пиров на всех серверах — только у core.
|
||||||
{"AUDIT", audit},
|
{"AUDIT", audit},
|
||||||
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
|
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
|
||||||
@@ -196,6 +210,7 @@ func New() *CLI {
|
|||||||
env: [][2]string{
|
env: [][2]string{
|
||||||
{"CORE_HOST", env("CORE_HOST", "")},
|
{"CORE_HOST", env("CORE_HOST", "")},
|
||||||
{"CORE_PORT", env("CORE_PORT", "")},
|
{"CORE_PORT", env("CORE_PORT", "")},
|
||||||
|
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
|
||||||
{"CORE_PIN", env("CORE_PIN", "")},
|
{"CORE_PIN", env("CORE_PIN", "")},
|
||||||
{"NODE_ID", env("NODE_ID", "")},
|
{"NODE_ID", env("NODE_ID", "")},
|
||||||
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
|
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
|
||||||
@@ -203,9 +218,17 @@ func New() *CLI {
|
|||||||
{"CLI_ENV_FILE", cliEnv},
|
{"CLI_ENV_FILE", cliEnv},
|
||||||
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
||||||
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
|
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
|
||||||
|
// Prometheus на ноде (`monitor install`): здесь он скрейпит метрики панели.
|
||||||
|
{"AGENT_METRICS_PORT", env("AGENT_METRICS_PORT", "")},
|
||||||
},
|
},
|
||||||
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"),
|
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"),
|
||||||
},
|
},
|
||||||
|
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
|
||||||
|
"relay": {
|
||||||
|
name: "relay", label: "awg-relay", cwd: c.root,
|
||||||
|
env: [][2]string{{"RELAY", env("RELAY", "")}},
|
||||||
|
pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.log"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
|
// Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
|
||||||
@@ -224,9 +247,41 @@ func New() *CLI {
|
|||||||
default:
|
default:
|
||||||
c.all = []string{"awgctrl", "ui"}
|
c.all = []string{"awgctrl", "ui"}
|
||||||
}
|
}
|
||||||
|
// Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db
|
||||||
|
// разошлась бы с перенесённой.
|
||||||
|
if c.panelMoved() {
|
||||||
|
c.all = without(c.all, "ui")
|
||||||
|
}
|
||||||
|
if os.Getenv("RELAY") != "" {
|
||||||
|
c.all = append(c.all, "relay")
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSvcEnv — значение, записанное в cli.env уже после запуска CLI, для следующего start.
|
||||||
|
func (c *CLI) setSvcEnv(name, key, value string) {
|
||||||
|
s := c.svc[name]
|
||||||
|
for i := range s.env {
|
||||||
|
if s.env[i][0] == key {
|
||||||
|
s.env[i][1] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.env = append(s.env, [2]string{key, value})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
|
||||||
|
|
||||||
|
func without(s []string, v string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, x := range s {
|
||||||
|
if x != v {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CLI) envOf(name string) string {
|
func (c *CLI) envOf(name string) string {
|
||||||
for _, kv := range c.svc[name].env {
|
for _, kv := range c.svc[name].env {
|
||||||
if kv[0] == "PORT" {
|
if kv[0] == "PORT" {
|
||||||
@@ -285,6 +340,9 @@ func (c *CLI) pidMatches(name string, pid int) bool {
|
|||||||
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
|
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if s.legacy == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
|
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
|
||||||
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
|
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
|
||||||
}
|
}
|
||||||
@@ -424,11 +482,33 @@ func (c *CLI) serviceLine(name string, menu bool) string {
|
|||||||
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
|
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() {
|
func (c *CLI) status() {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
for _, n := range c.all {
|
for _, n := range c.all {
|
||||||
fmt.Println(c.serviceLine(n, false))
|
fmt.Println(c.serviceLine(n, false))
|
||||||
}
|
}
|
||||||
|
if contains(c.all, "ui") {
|
||||||
|
if u := panelURL(); u != "" {
|
||||||
|
fmt.Println(" " + dim("Панель "+u))
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.isRunning("awgctrl") {
|
if c.isRunning("awgctrl") {
|
||||||
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
|
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
|
||||||
if gen, ok := get(h, "gen").(string); ok && gen != "" {
|
if gen, ok := get(h, "gen").(string); ok && gen != "" {
|
||||||
@@ -476,10 +556,14 @@ func (c *CLI) startAll() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
|
||||||
|
if contains(c.all, "relay") {
|
||||||
|
c.start("relay")
|
||||||
|
}
|
||||||
c.start("awgctrl")
|
c.start("awgctrl")
|
||||||
var rest, labels []string
|
var rest, labels []string
|
||||||
for _, n := range c.all {
|
for _, n := range c.all {
|
||||||
if n != "awgctrl" {
|
if n != "awgctrl" && n != "relay" {
|
||||||
rest = append(rest, n)
|
rest = append(rest, n)
|
||||||
labels = append(labels, c.svc[n].label)
|
labels = append(labels, c.svc[n].label)
|
||||||
}
|
}
|
||||||
@@ -515,23 +599,9 @@ func contains(s []string, v string) bool {
|
|||||||
|
|
||||||
// ── cli.env ─────────────────────────────────────────────────────────────────
|
// ── cli.env ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func updateEnvVar(content, key, value string) string {
|
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
|
||||||
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
|
|
||||||
if loc := re.FindStringIndex(content); loc != nil {
|
|
||||||
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
|
|
||||||
}
|
|
||||||
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeEnvVar(content, key string) string {
|
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
|
||||||
var kept []string
|
|
||||||
for _, l := range strings.Split(content, "\n") {
|
|
||||||
if !strings.HasPrefix(l, key+"=") {
|
|
||||||
kept = append(kept, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(kept, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CLI) readEnvFile() string {
|
func (c *CLI) readEnvFile() string {
|
||||||
raw, _ := os.ReadFile(c.envFile)
|
raw, _ := os.ReadFile(c.envFile)
|
||||||
@@ -568,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|peers [drop <область>]|monitor <install|status|remove>>"
|
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 передаёт сюда всё, что не роль сервиса.
|
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "peers", "monitor"}
|
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 — без имени бинарника). Пусто — интерактивное меню.
|
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
||||||
func Main(args []string) {
|
func Main(args []string) {
|
||||||
@@ -608,10 +678,27 @@ func Main(args []string) {
|
|||||||
c.joinCore(sub, hasSub, a2, hasA2)
|
c.joinCore(sub, hasSub, a2, hasA2)
|
||||||
case "unjoin":
|
case "unjoin":
|
||||||
c.unjoinCore()
|
c.unjoinCore()
|
||||||
|
case "rehome":
|
||||||
|
c.rehome(sub, hasSub)
|
||||||
|
case "migrate":
|
||||||
|
c.migrateCmd(sub, a2, hasA2)
|
||||||
case "peers":
|
case "peers":
|
||||||
c.peersCmd(sub, hasSub, a2, hasA2)
|
c.peersCmd(sub, hasSub, a2, hasA2)
|
||||||
case "monitor":
|
case "monitor":
|
||||||
c.monitorCmd(sub)
|
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:
|
default:
|
||||||
fmt.Println(usage)
|
fmt.Println(usage)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+70
-4
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -61,6 +62,10 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
|||||||
sec, sok := j.Get2("s").(string)
|
sec, sok := j.Get2("s").(string)
|
||||||
port, pok := isPosInt(j.Get2("p"), 65536)
|
port, pok := isPosInt(j.Get2("p"), 65536)
|
||||||
id, iok := isPosInt(j.Get2("n"), 9007199254740992)
|
id, iok := isPosInt(j.Get2("n"), 9007199254740992)
|
||||||
|
// Нода #0 — этот же сервер после переноса панели (`migrate import` выдаёт строку с n = 0).
|
||||||
|
if n, ok := j.Get2("n").(float64); ok && n == 0 {
|
||||||
|
id, iok = 0, true
|
||||||
|
}
|
||||||
if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
|
if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
|
||||||
!iok || !sok || !reJoinSec.MatchString(sec) {
|
!iok || !sok || !reJoinSec.MatchString(sec) {
|
||||||
errorf(" Ссылка неверного формата или от другой версии панели")
|
errorf(" Ссылка неверного формата или от другой версии панели")
|
||||||
@@ -71,7 +76,8 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasPanel := os.Getenv("UI_PASS") != ""
|
hasPanel := os.Getenv("UI_PASS") != ""
|
||||||
keepPanel := !hasFlag && hasPanel
|
moved := c.panelMoved()
|
||||||
|
keepPanel := !hasFlag && hasPanel && !moved
|
||||||
content := c.readEnvFile()
|
content := c.readEnvFile()
|
||||||
np := "off"
|
np := "off"
|
||||||
if keepPanel {
|
if keepPanel {
|
||||||
@@ -81,11 +87,18 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
|||||||
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
|
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
|
||||||
content = updateEnvVar(content, kv[0], kv[1])
|
content = updateEnvVar(content, kv[0], kv[1])
|
||||||
}
|
}
|
||||||
|
content = removeEnvVar(content, "CORE_ENDPOINT") // адрес прежней панели к новой не относится
|
||||||
|
relay := ""
|
||||||
|
if moved {
|
||||||
|
relay = relayRoutes(h, port, j)
|
||||||
|
content = updateEnvVar(content, "RELAY", relay)
|
||||||
|
}
|
||||||
c.writeEnvFile(content)
|
c.writeEnvFile(content)
|
||||||
|
|
||||||
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
|
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
|
||||||
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
|
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
|
||||||
dropPeers := !keepPanel && hasPanel && c.askDropPanelPeers()
|
// После переноса этими пирами управляет новая панель: снимать нечего.
|
||||||
|
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
|
||||||
if !keepPanel {
|
if !keepPanel {
|
||||||
c.stop("ui")
|
c.stop("ui")
|
||||||
}
|
}
|
||||||
@@ -99,9 +112,35 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
|||||||
tail = " · своя панель остановлена"
|
tail = " · своя панель остановлена"
|
||||||
}
|
}
|
||||||
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
|
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
|
||||||
|
if relay != "" {
|
||||||
|
fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay))
|
||||||
|
}
|
||||||
c.restartFresh()
|
c.restartFresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relayRoutes — прежние порты этого сервера → новая панель. Подписка и хаб там на тех же
|
||||||
|
// портах (так их ставит `migrate import`), порт панели — из поля ui join-строки.
|
||||||
|
func relayRoutes(host string, hubPort int64, j jsjson.Object) string {
|
||||||
|
hp := func(p any) string { return net.JoinHostPort(host, fmt.Sprint(p)) }
|
||||||
|
var routes []string
|
||||||
|
add := func(local int, target string) {
|
||||||
|
if local > 0 {
|
||||||
|
routes = append(routes, fmt.Sprintf("%d=%s", local, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub := envPort("SUB_PORT")
|
||||||
|
if p, ok := isPosInt(j.Get2("sub"), 65536); ok {
|
||||||
|
add(sub, hp(p))
|
||||||
|
} else {
|
||||||
|
add(sub, hp(sub))
|
||||||
|
}
|
||||||
|
add(envPort("NODE_PORT"), hp(hubPort))
|
||||||
|
if p, ok := isPosInt(j.Get2("ui"), 65536); ok {
|
||||||
|
add(envPort("UI_PORT"), hp(p))
|
||||||
|
}
|
||||||
|
return strings.Join(routes, ",")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CLI) unjoinCore() {
|
func (c *CLI) unjoinCore() {
|
||||||
if !c.isNode {
|
if !c.isNode {
|
||||||
fmt.Println(" Эта установка не подключена к core")
|
fmt.Println(" Эта установка не подключена к core")
|
||||||
@@ -109,7 +148,7 @@ func (c *CLI) unjoinCore() {
|
|||||||
}
|
}
|
||||||
c.stop("agent")
|
c.stop("agent")
|
||||||
content := c.readEnvFile()
|
content := c.readEnvFile()
|
||||||
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"} {
|
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL", "CORE_ENDPOINT"} {
|
||||||
content = removeEnvVar(content, k)
|
content = removeEnvVar(content, k)
|
||||||
}
|
}
|
||||||
c.writeEnvFile(content)
|
c.writeEnvFile(content)
|
||||||
@@ -415,6 +454,9 @@ func (c *CLI) setCredentials(field string) {
|
|||||||
if pass != nil {
|
if pass != nil {
|
||||||
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
|
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 чтобы изменения вступили в силу."))
|
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,9 +491,19 @@ func (c *CLI) interactiveMenu() {
|
|||||||
%s Сменить логин UI
|
%s Сменить логин UI
|
||||||
%s Сменить пароль UI
|
%s Сменить пароль UI
|
||||||
%s
|
%s
|
||||||
|
%s Обновить
|
||||||
|
%s Переустановить
|
||||||
|
%s Удалить
|
||||||
|
%s
|
||||||
|
%s Адрес панели и секретный путь
|
||||||
|
%s Журнал подключений
|
||||||
|
%s Кто когда был в VPN
|
||||||
|
%s Проверка скрытности
|
||||||
|
%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 {
|
for {
|
||||||
printMenu()
|
printMenu()
|
||||||
@@ -476,6 +528,20 @@ func (c *CLI) interactiveMenu() {
|
|||||||
c.setCredentials("user")
|
c.setCredentials("user")
|
||||||
case "6":
|
case "6":
|
||||||
c.setCredentials("pass")
|
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":
|
case "0":
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
|
||||||
|
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
|
||||||
|
//
|
||||||
|
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
|
||||||
|
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
|
||||||
|
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
"github.com/maeneko/forgetting/internal/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func envPort(k string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
|
||||||
|
if err != nil || n <= 0 || n > 65535 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CLI) paths() migrate.Paths {
|
||||||
|
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
|
||||||
|
switch sub {
|
||||||
|
case "export":
|
||||||
|
c.migrateExport(arg, hasArg)
|
||||||
|
case "import":
|
||||||
|
if !hasArg {
|
||||||
|
errorf(" Usage: awg-ctrl migrate import <архив>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
c.migrateImport(arg)
|
||||||
|
case "cancel":
|
||||||
|
c.migrateCancel()
|
||||||
|
default:
|
||||||
|
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── export (старый сервер) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (c *CLI) migrateExport(out string, hasOut bool) {
|
||||||
|
if c.isNode {
|
||||||
|
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if !contains(c.svcList(), "awgctrl") {
|
||||||
|
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
dbFile := c.paths()["ui.db"]
|
||||||
|
if !exists(dbFile) {
|
||||||
|
errorf(" Нет базы панели: %s", dbFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if !hasOut {
|
||||||
|
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
|
||||||
|
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
|
||||||
|
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
|
||||||
|
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
|
||||||
|
if !c.confirmYes(" Продолжить?") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.stop("ui")
|
||||||
|
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
|
||||||
|
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
|
||||||
|
if err := c.writeEnvFile(content); err != nil {
|
||||||
|
errorf(" Не удалось записать %s: %v", c.envFile, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := c.writeExport(out, dbFile); err != nil {
|
||||||
|
errorf(" Архив не создан: %v", err)
|
||||||
|
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
|
||||||
|
os.Remove(out)
|
||||||
|
c.start("ui")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
|
||||||
|
fmt.Println("\n Дальше:")
|
||||||
|
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
|
||||||
|
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
|
||||||
|
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
|
||||||
|
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
|
||||||
|
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
|
||||||
|
func (c *CLI) svcList() []string {
|
||||||
|
return without(without(c.all, "relay"), "ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CLI) writeExport(out, dbFile string) error {
|
||||||
|
db, err := migrate.OpenDB(dbFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
name, ip := "VPN", os.Getenv("SERVER_IP")
|
||||||
|
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
|
||||||
|
name = strOr(get(h, "server"), name)
|
||||||
|
if ip == "" {
|
||||||
|
ip = strOr(get(h, "ip"), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
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"), env("UI_PREFIX", "")))
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── import (новая панель) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (c *CLI) migrateImport(file string) {
|
||||||
|
if c.isNode {
|
||||||
|
errorf(" Это нода — импортировать панель нужно на core")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if contains(c.svcList(), "awgctrl") {
|
||||||
|
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
errorf(" %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
b, err := migrate.Read(f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
errorf(" %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
paths := c.paths()
|
||||||
|
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
|
||||||
|
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
|
||||||
|
for host == "" || !reJoinHost.MatchString(host) {
|
||||||
|
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
|
||||||
|
if host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
|
||||||
|
if err != nil {
|
||||||
|
errorf(" %v (%s)", err, c.envFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
from := b.Str("server_ip")
|
||||||
|
if v := b.Str("version"); v != "" {
|
||||||
|
from += ", версия " + v
|
||||||
|
}
|
||||||
|
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
|
||||||
|
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
|
||||||
|
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
|
||||||
|
if !b.Has["sub_sign.key"] {
|
||||||
|
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
|
||||||
|
}
|
||||||
|
if !c.confirmYes(" Продолжить?") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.stop("ui")
|
||||||
|
secret := make([]byte, 16)
|
||||||
|
rand.Read(secret)
|
||||||
|
if err := migrate.Install(b, paths); err != nil {
|
||||||
|
errorf(" %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
|
||||||
|
errorf(" ui.db: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
content := c.readEnvFile()
|
||||||
|
for _, kv := range plan.Env {
|
||||||
|
content = updateEnvVar(content, kv[0], kv[1])
|
||||||
|
}
|
||||||
|
if err := c.writeEnvFile(content); err != nil {
|
||||||
|
errorf(" Не удалось записать %s: %v", c.envFile, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh.
|
||||||
|
opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort)
|
||||||
|
if len(opened) > 0 {
|
||||||
|
fmt.Printf(" %s UFW: открыты %s\n", green("✓"), strings.Join(opened, " "))
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
errorf(" UFW: не удалось открыть %s — откройте вручную: ufw allow <порт>/tcp", strings.Join(failed, " "))
|
||||||
|
}
|
||||||
|
c.restartFresh()
|
||||||
|
|
||||||
|
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
|
||||||
|
var pin string
|
||||||
|
for range 50 {
|
||||||
|
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
|
||||||
|
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
|
||||||
|
fmt.Println(" awg-ctrl join '" + join + "'")
|
||||||
|
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 (старый сервер) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (c *CLI) migrateCancel() {
|
||||||
|
if !c.panelMoved() {
|
||||||
|
fmt.Println(" Переноса не было: панель на месте")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.isNode {
|
||||||
|
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
|
||||||
|
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
content := c.readEnvFile()
|
||||||
|
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
|
||||||
|
content = removeEnvVar(content, k)
|
||||||
|
}
|
||||||
|
c.writeEnvFile(content)
|
||||||
|
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
|
||||||
|
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
|
||||||
|
c.restartFresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rehome (нода) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
|
||||||
|
func (c *CLI) rehome(addr string, hasAddr bool) {
|
||||||
|
if !c.isNode {
|
||||||
|
errorf(" Это не нода — менять адрес core нечему")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
host, port := addr, ""
|
||||||
|
if h, p, err := net.SplitHostPort(addr); err == nil {
|
||||||
|
host, port = h, p
|
||||||
|
}
|
||||||
|
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
|
||||||
|
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
|
||||||
|
if port != "" {
|
||||||
|
content = updateEnvVar(content, "CORE_PORT", port)
|
||||||
|
} else {
|
||||||
|
port = os.Getenv("CORE_PORT")
|
||||||
|
}
|
||||||
|
// Адрес, указанный руками, главнее того, что сообщила панель.
|
||||||
|
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
|
||||||
|
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
|
||||||
|
c.restartFresh()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
"github.com/maeneko/forgetting/internal/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodeJoin(t *testing.T, s string) jsjson.Object {
|
||||||
|
t.Helper()
|
||||||
|
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
|
||||||
|
o, ok := v.(jsjson.Object)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("join-строка не разбирается: %v", err)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
|
||||||
|
func TestWriteExportFromEnv(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sign := filepath.Join(dir, "sign")
|
||||||
|
os.WriteFile(sign, []byte("sign"), 0o600)
|
||||||
|
dbFile := filepath.Join(dir, "ui.db")
|
||||||
|
db, err := migrate.OpenDB(dbFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||||
|
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
|
||||||
|
db.Close()
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
|
||||||
|
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
|
||||||
|
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
|
||||||
|
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
|
||||||
|
} {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, "bundle.tar.gz")
|
||||||
|
if err := New().writeExport(out, dbFile); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
f, _ := os.Open(out)
|
||||||
|
b, err := migrate.Read(f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
|
||||||
|
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
|
||||||
|
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
|
||||||
|
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayRoutes(t *testing.T) {
|
||||||
|
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
|
||||||
|
t.Setenv("SUB_PORT", "40123")
|
||||||
|
t.Setenv("NODE_PORT", "40400")
|
||||||
|
t.Setenv("UI_PORT", "32000")
|
||||||
|
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
|
||||||
|
t.Errorf("relay = %s, ожидали %s", got, want)
|
||||||
|
}
|
||||||
|
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
|
||||||
|
t.Setenv("NODE_PORT", "")
|
||||||
|
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
|
||||||
|
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
|
||||||
|
t.Errorf("relay = %s, ожидали %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-5
@@ -4,6 +4,8 @@ package cli
|
|||||||
// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не
|
// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не
|
||||||
// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает
|
// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает
|
||||||
// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине.
|
// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине.
|
||||||
|
// На ноде без своей панели и скрейп, и чтение идут через агента: метрики панели он берёт у
|
||||||
|
// core по WSS (AGENT_METRICS_PORT), а запросы вкладки «Монитор» пропускает к Prometheus.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -11,6 +13,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -121,7 +125,8 @@ func copyFile(src, dst string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePromConfig(uiPort string) {
|
// writePromConfig — target: порт панели на этой машине или агента ноды.
|
||||||
|
func writePromConfig(target string) {
|
||||||
if exists(promConf) {
|
if exists(promConf) {
|
||||||
copyFile(promConf, promConf+".bak-"+backupStamp())
|
copyFile(promConf, promConf+".bak-"+backupStamp())
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,7 @@ scrape_configs:
|
|||||||
scheme: http
|
scheme: http
|
||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
`+authBlock()+` static_configs:
|
`+authBlock()+` static_configs:
|
||||||
- targets: ['127.0.0.1:`+uiPort+`']
|
- targets: ['127.0.0.1:`+target+`']
|
||||||
`), 0o644)
|
`), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,8 +177,22 @@ func (c *CLI) monitorInstall() {
|
|||||||
if !reDebian.MatchString(osFamily()) {
|
if !reDebian.MatchString(osFamily()) {
|
||||||
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
|
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
|
||||||
}
|
}
|
||||||
uiPort := os.Getenv("UI_PORT")
|
// Нода без своей панели (в том числе сервер, с которого панель переехала): метрики панели
|
||||||
if uiPort == "" {
|
// Prometheus получает от агента, а тот — от core.
|
||||||
|
viaAgent := c.isNode && !contains(c.all, "ui")
|
||||||
|
target, newPort := os.Getenv("UI_PORT"), false
|
||||||
|
if viaAgent {
|
||||||
|
if target = os.Getenv("AGENT_METRICS_PORT"); target == "" {
|
||||||
|
if target = freeLocalPort(); target == "" {
|
||||||
|
mfail("Не нашлось свободного порта для агента")
|
||||||
|
}
|
||||||
|
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "AGENT_METRICS_PORT", target)); err != nil {
|
||||||
|
mfail("Не удалось записать " + c.envFile + ": " + err.Error())
|
||||||
|
}
|
||||||
|
c.setSvcEnv("agent", "AGENT_METRICS_PORT", target)
|
||||||
|
newPort = true
|
||||||
|
}
|
||||||
|
} else if target == "" {
|
||||||
mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.")
|
mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.")
|
||||||
}
|
}
|
||||||
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
|
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
|
||||||
@@ -195,7 +214,12 @@ func (c *CLI) monitorInstall() {
|
|||||||
|
|
||||||
mlog("2/4 токен и конфиг")
|
mlog("2/4 токен и конфиг")
|
||||||
writePromToken()
|
writePromToken()
|
||||||
writePromConfig(uiPort)
|
writePromConfig(target)
|
||||||
|
if newPort {
|
||||||
|
mlog("агент отдаёт метрики панели на 127.0.0.1:" + target + " — перезапуск агента")
|
||||||
|
c.stop("agent")
|
||||||
|
c.start("agent")
|
||||||
|
}
|
||||||
|
|
||||||
mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention)
|
mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention)
|
||||||
setPromArgs()
|
setPromArgs()
|
||||||
@@ -208,6 +232,9 @@ func (c *CLI) monitorInstall() {
|
|||||||
for range 30 {
|
for range 30 {
|
||||||
if promReady() {
|
if promReady() {
|
||||||
fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen)
|
fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen)
|
||||||
|
if viaAgent {
|
||||||
|
fmt.Print(" Панель читает его через агента этой ноды.\n\n")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
@@ -215,6 +242,22 @@ func (c *CLI) monitorInstall() {
|
|||||||
mfail("Prometheus не ответил за 30 с: journalctl -u prometheus")
|
mfail("Prometheus не ответил за 30 с: journalctl -u prometheus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freeLocalPort — свободный порт из того же диапазона, что у install.sh (32768–55535).
|
||||||
|
func freeLocalPort() string {
|
||||||
|
for range 50 {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(55535-32768+1))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
p := strconv.Itoa(32768 + int(n.Int64()))
|
||||||
|
if l, err := net.Listen("tcp", "127.0.0.1:"+p); err == nil {
|
||||||
|
l.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func yesNo(b bool, yes, no string) string {
|
func yesNo(b bool, yes, no string) string {
|
||||||
if b {
|
if b {
|
||||||
return yes
|
return yes
|
||||||
|
|||||||
@@ -344,8 +344,11 @@ func (s *Server) syncPeers() error {
|
|||||||
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
|
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
|
||||||
func (s *Server) reloadAwgParams() string {
|
func (s *Server) reloadAwgParams() string {
|
||||||
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
|
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.cfgMu.Lock()
|
||||||
s.c.Params = p
|
s.c.Params = p
|
||||||
|
s.c.MTU = mtu
|
||||||
s.c.Gen = p.Gen()
|
s.c.Gen = p.Gen()
|
||||||
s.c.Keepalive = keepaliveByGen[s.c.Gen]
|
s.c.Keepalive = keepaliveByGen[s.c.Gen]
|
||||||
g := s.c.Gen
|
g := s.c.Gen
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,26 @@ func initSchema(db *sql.DB) error {
|
|||||||
psk_key TEXT NOT NULL,
|
psk_key TEXT NOT NULL,
|
||||||
owner TEXT NOT NULL DEFAULT '',
|
owner TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL
|
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 {
|
if _, err := db.Exec(q); err != nil {
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,28 @@ var AWG31Defaults = [][2]string{
|
|||||||
{"MaxHandshakeAttempts", "15-20"},
|
{"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.
|
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12.
|
||||||
const minSForHeaderProtection = 12
|
const minSForHeaderProtection = 12
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ func (s *Server) routes() []route {
|
|||||||
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
|
{"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) {
|
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
|
||||||
name, _ := c.obj().Get2("name").(string)
|
name, _ := c.obj().Get2("name").(string)
|
||||||
if u, err := s.userGet(name); err != nil {
|
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)}})
|
{"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) {
|
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
|
||||||
if err := s.restartAwg(); err != nil {
|
if err := s.restartAwg(); err != nil {
|
||||||
return result{}, err
|
return result{}, err
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type Server struct {
|
|||||||
|
|
||||||
shaping *Shaping
|
shaping *Shaping
|
||||||
torrent *Torrent
|
torrent *Torrent
|
||||||
|
connlog *ConnLog
|
||||||
metrics metricsCache
|
metrics metricsCache
|
||||||
monitor monitorJob
|
monitor monitorJob
|
||||||
cliPath string
|
cliPath string
|
||||||
@@ -146,17 +147,19 @@ func New() (*Server, error) {
|
|||||||
sp, _ := strconv.Atoi(portStr)
|
sp, _ := strconv.Atoi(portStr)
|
||||||
|
|
||||||
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
|
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
|
||||||
|
params.I1 = s.ensureI1() // I1 — из БД, не из conf (см. i1.go)
|
||||||
gen := params.Gen()
|
gen := params.Gen()
|
||||||
module, tools := readVersions()
|
module, tools := readVersions()
|
||||||
s.c = Config{
|
s.c = Config{
|
||||||
ServerIP: ip, ServerPort: sp, ServerName: name,
|
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,
|
Keepalive: keepaliveByGen[gen], Params: params, Gen: gen,
|
||||||
AwgModule: module, AwgTools: tools,
|
AwgModule: module, AwgTools: tools,
|
||||||
}
|
}
|
||||||
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
|
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
|
||||||
s.shaping = newShaping(s)
|
s.shaping = newShaping(s)
|
||||||
s.torrent = newTorrent(s)
|
s.torrent = newTorrent(s)
|
||||||
|
s.connlog = newConnLog(s)
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,5 +188,6 @@ func (s *Server) Run() error {
|
|||||||
go s.shaping.Apply()
|
go s.shaping.Apply()
|
||||||
s.shaping.Start()
|
s.shaping.Start()
|
||||||
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
|
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
|
||||||
|
s.connlog.Start()
|
||||||
return <-errc
|
return <-errc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Package envfile — правка cli.env (KEY=value построчно): CLI и панель пишут в него одинаково.
|
||||||
|
package envfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update — как String.replace без g: первое KEY=…, а нет его — строка в конец.
|
||||||
|
func Update(content, key, value string) string {
|
||||||
|
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
|
||||||
|
if loc := re.FindStringIndex(content); loc != nil {
|
||||||
|
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
|
||||||
|
}
|
||||||
|
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func Remove(content, key string) string {
|
||||||
|
var kept []string
|
||||||
|
for _, l := range strings.Split(content, "\n") {
|
||||||
|
if !strings.HasPrefix(l, key+"=") {
|
||||||
|
kept = append(kept, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set — задать set и убрать remove в файле. Новый файл — 0600 (в нём пароль панели).
|
||||||
|
func Set(file string, set [][2]string, remove ...string) error {
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := string(raw)
|
||||||
|
for _, kv := range set {
|
||||||
|
content = Update(content, kv[0], kv[1])
|
||||||
|
}
|
||||||
|
for _, k := range remove {
|
||||||
|
content = Remove(content, k)
|
||||||
|
}
|
||||||
|
return os.WriteFile(file, []byte(content), 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ufw — вызов ufw (в тестах подменяется). Нет ufw — ошибка, и правила не трогаются.
|
||||||
|
var ufw = func(args ...string) ([]byte, error) {
|
||||||
|
if _, err := exec.LookPath("ufw"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return exec.Command("ufw", args...).CombinedOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFirewall — открыть в UFW порты, на которые панель переезжает при приёме: подписка и хаб
|
||||||
|
// берут порты старого сервера, а install.sh открыл только те, что были при установке. Без этого
|
||||||
|
// нода #0 не дозвонится до хаба, а ретранслятор — до подписки. UFW не установлен или не активен —
|
||||||
|
// ничего не делаем (как install.sh). opened — что открыто, failed — что не удалось.
|
||||||
|
func OpenFirewall(ports ...int) (opened, failed []string) {
|
||||||
|
out, err := ufw("status")
|
||||||
|
if err != nil || !strings.Contains(strings.ToLower(string(out)), "status: active") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for _, p := range ports {
|
||||||
|
if p <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule := strconv.Itoa(p) + "/tcp"
|
||||||
|
if _, err := ufw("allow", rule); err != nil {
|
||||||
|
failed = append(failed, rule)
|
||||||
|
} else {
|
||||||
|
opened = append(opened, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opened) > 0 {
|
||||||
|
ufw("reload")
|
||||||
|
}
|
||||||
|
return opened, failed
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
|
||||||
|
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
|
||||||
|
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
|
||||||
|
// docs/node-protocol.md, «Перенос панели».
|
||||||
|
//
|
||||||
|
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
|
||||||
|
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
|
||||||
|
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
|
||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
var b64url = base64.RawURLEncoding
|
||||||
|
|
||||||
|
const (
|
||||||
|
Version = 1
|
||||||
|
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
|
||||||
|
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
|
||||||
|
)
|
||||||
|
|
||||||
|
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
|
||||||
|
type File struct {
|
||||||
|
Name, Env string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var Files = []File{
|
||||||
|
{"ui.db", "UI_DB_FILE", true},
|
||||||
|
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
|
||||||
|
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
|
||||||
|
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
|
||||||
|
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
|
||||||
|
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
|
||||||
|
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
|
||||||
|
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths — имя файла в архиве → путь на этой машине.
|
||||||
|
type Paths map[string]string
|
||||||
|
|
||||||
|
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
|
||||||
|
func PathsFrom(get func(env string) string) Paths {
|
||||||
|
p := Paths{}
|
||||||
|
for _, f := range Files {
|
||||||
|
p[f.Name] = get(f.Env)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDB — ui.db тем же драйвером, что у awg-ui.
|
||||||
|
func OpenDB(path string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Архив ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
|
||||||
|
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
|
||||||
|
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
|
||||||
|
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
snap := filepath.Join(tmp, "ui.db")
|
||||||
|
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
|
||||||
|
return fmt.Errorf("снимок ui.db: %w", err)
|
||||||
|
}
|
||||||
|
var panelID string
|
||||||
|
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
|
||||||
|
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
|
||||||
|
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
add := func(name string, data []byte) error {
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tw.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = add("meta.json", jsjson.MustMarshal(meta))
|
||||||
|
for _, f := range Files {
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
src := p[f.Name]
|
||||||
|
if f.Name == "ui.db" {
|
||||||
|
src = snap
|
||||||
|
}
|
||||||
|
raw, rerr := os.ReadFile(src)
|
||||||
|
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
err = rerr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = add(f.Name, raw)
|
||||||
|
}
|
||||||
|
if cerr := tw.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if cerr := gz.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bundle struct {
|
||||||
|
Dir string
|
||||||
|
Meta jsjson.Object
|
||||||
|
Has map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
|
||||||
|
|
||||||
|
func (b *Bundle) Num(k string) int {
|
||||||
|
f, _ := b.Meta.Get2(k).(float64)
|
||||||
|
return int(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bundle) Str(k string) string {
|
||||||
|
s, _ := b.Meta.Get2(k).(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
|
||||||
|
func Read(r io.Reader) (*Bundle, error) {
|
||||||
|
gz, err := gzip.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не архив переноса: %w", err)
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "forgetting-import-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := &Bundle{Dir: dir, Has: map[string]bool{}}
|
||||||
|
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
|
||||||
|
allowed := map[string]bool{"meta.json": true}
|
||||||
|
for _, f := range Files {
|
||||||
|
allowed[f.Name] = true
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("архив повреждён: %w", err))
|
||||||
|
}
|
||||||
|
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
|
||||||
|
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
|
||||||
|
}
|
||||||
|
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if err == nil {
|
||||||
|
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
|
||||||
|
if cerr := dst.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
b.Has[h.Name] = true
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||||
|
var v any
|
||||||
|
if err == nil {
|
||||||
|
v, err = jsjson.Decode(raw)
|
||||||
|
}
|
||||||
|
meta, ok := v.(jsjson.Object)
|
||||||
|
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
|
||||||
|
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
|
||||||
|
}
|
||||||
|
b.Meta = meta
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
|
||||||
|
func PanelData(file string) (int, error) {
|
||||||
|
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
db, err := OpenDB(file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return Count(db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Count(db *sql.DB) int {
|
||||||
|
total := 0
|
||||||
|
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
|
||||||
|
var n int
|
||||||
|
// Таблицы может не быть — awg-ui ещё не запускался.
|
||||||
|
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Раскладка на новой панели ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
|
||||||
|
// ссылках, у нод и в DNS, если адрес — домен.
|
||||||
|
type Plan struct {
|
||||||
|
SubPort, NodePort, UIPort int
|
||||||
|
Host string
|
||||||
|
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}
|
||||||
|
if p.SubPort == 0 {
|
||||||
|
p.SubPort = env("SUB_PORT")
|
||||||
|
}
|
||||||
|
if p.NodePort == 0 {
|
||||||
|
p.NodePort = env("NODE_PORT")
|
||||||
|
}
|
||||||
|
if p.NodePort == 0 {
|
||||||
|
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
|
||||||
|
}
|
||||||
|
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
|
||||||
|
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
|
||||||
|
}
|
||||||
|
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
|
||||||
|
if kv[1] != "0" && kv[1] != "" {
|
||||||
|
p.Env = append(p.Env, kv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tlsOn := "off"
|
||||||
|
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
|
||||||
|
tlsOn = "on"
|
||||||
|
}
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
|
||||||
|
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
|
||||||
|
func (p *Plan) TrustRelay(ip string) {
|
||||||
|
if ip == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, kv := range p.Env {
|
||||||
|
if kv[0] == "RELAY_FROM" {
|
||||||
|
for _, have := range strings.Split(kv[1], ",") {
|
||||||
|
if have == ip {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Env[i][1] += "," + ip
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
|
||||||
|
func Install(b *Bundle, p Paths) error {
|
||||||
|
stamp := time.Now().Format("20060102-150405")
|
||||||
|
for _, f := range Files {
|
||||||
|
if !b.Has[f.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dst := p[f.Name]
|
||||||
|
olds := []string{dst}
|
||||||
|
if f.Name == "ui.db" {
|
||||||
|
olds = append(olds, dst+"-wal", dst+"-shm")
|
||||||
|
}
|
||||||
|
for _, o := range olds {
|
||||||
|
if _, err := os.Stat(o); err == nil {
|
||||||
|
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
|
||||||
|
return fmt.Errorf("не удалось отложить %s: %w", o, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
|
||||||
|
if err == nil {
|
||||||
|
err = os.MkdirAll(filepath.Dir(dst), 0o700)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.WriteFile(dst, raw, 0o600)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось записать %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
|
||||||
|
func AddNodeZero(dbFile, name string, secret []byte) error {
|
||||||
|
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
|
||||||
|
name = "VPN"
|
||||||
|
}
|
||||||
|
db, err := OpenDB(dbFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
h := sha256.Sum256(secret)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
|
||||||
|
name, h[:], now+JoinTTL, now); err != nil {
|
||||||
|
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
|
||||||
|
func CertPin(file string) (string, error) {
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
blk, _ := pem.Decode(raw)
|
||||||
|
if blk == nil {
|
||||||
|
return "", errors.New("не PEM")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(blk.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||||
|
return b64url.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
|
||||||
|
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
|
||||||
|
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
|
||||||
|
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
|
||||||
|
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
|
||||||
|
if uiPort > 0 {
|
||||||
|
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
|
||||||
|
}
|
||||||
|
if subPort > 0 {
|
||||||
|
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
|
||||||
|
}
|
||||||
|
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDB(t *testing.T, file string) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := OpenDB(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for _, q := range []string{
|
||||||
|
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||||
|
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
|
||||||
|
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
|
||||||
|
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
|
||||||
|
"INSERT INTO master_keys VALUES (1)",
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(q); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
|
||||||
|
// снимком, в котором новая панель заводит ноду #0.
|
||||||
|
func TestBundleRoundTrip(t *testing.T) {
|
||||||
|
src, dst := t.TempDir(), t.TempDir()
|
||||||
|
paths := func(dir string) Paths {
|
||||||
|
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
|
||||||
|
}
|
||||||
|
from, to := paths(src), paths(dst)
|
||||||
|
testDB(t, from["ui.db"])
|
||||||
|
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
|
||||||
|
os.WriteFile(from[name], []byte(v), 0o600)
|
||||||
|
}
|
||||||
|
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
|
||||||
|
|
||||||
|
db, _ := OpenDB(from["ui.db"])
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
|
||||||
|
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
|
||||||
|
db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := Read(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
|
||||||
|
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
|
||||||
|
}
|
||||||
|
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
|
||||||
|
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
|
||||||
|
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
|
||||||
|
t.Errorf("env: %s", got)
|
||||||
|
}
|
||||||
|
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
|
||||||
|
t.Error("UI_PORT = порт хаба: ожидали ошибку")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Install(b, to); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
|
||||||
|
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
|
||||||
|
t.Errorf("%s: %q", name, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
|
||||||
|
t.Errorf("прежний ключ не отложен: %v", baks)
|
||||||
|
}
|
||||||
|
if n, _ := PanelData(to["ui.db"]); n != 1 {
|
||||||
|
t.Errorf("PanelData(снимок) = %d", n)
|
||||||
|
}
|
||||||
|
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, _ = OpenDB(to["ui.db"])
|
||||||
|
var name string
|
||||||
|
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
|
||||||
|
db.Close()
|
||||||
|
if name != "VPN" {
|
||||||
|
t.Errorf("нода #0: %q", name)
|
||||||
|
}
|
||||||
|
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
|
||||||
|
t.Error("вторая нода #0: ожидали ошибку")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRejectsForeignEntries(t *testing.T) {
|
||||||
|
for name, entries := range map[string][]string{
|
||||||
|
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
|
||||||
|
"без базы": {"meta.json"},
|
||||||
|
"повтор": {"meta.json", "ui.db", "ui.db"},
|
||||||
|
} {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for _, e := range entries {
|
||||||
|
data := []byte("x")
|
||||||
|
if e == "meta.json" {
|
||||||
|
data = []byte(`{"v":1}`)
|
||||||
|
}
|
||||||
|
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
|
||||||
|
tw.Write(data)
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
if b, err := Read(&buf); err == nil {
|
||||||
|
b.Close()
|
||||||
|
t.Errorf("%s: архив принят", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinString(t *testing.T) {
|
||||||
|
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
|
||||||
|
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
|
||||||
|
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
|
||||||
|
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
|
||||||
|
t.Errorf("join: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeAndSeal(t *testing.T) {
|
||||||
|
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
|
||||||
|
got, err := ParseCode(" " + c.String() + "\n")
|
||||||
|
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
|
||||||
|
t.Fatalf("ParseCode: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
|
||||||
|
t.Errorf("URL: %s", u)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
|
||||||
|
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
|
||||||
|
if _, err := ParseCode(bad); err == nil {
|
||||||
|
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
box := SealRequest(c.Key, []byte("архив"))
|
||||||
|
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
|
||||||
|
t.Errorf("OpenRequest: %q %v", plain, err)
|
||||||
|
}
|
||||||
|
if _, err := OpenResponse(c.Key, box); err == nil {
|
||||||
|
t.Error("запрос открылся как ответ")
|
||||||
|
}
|
||||||
|
if _, err := OpenRequest(NewKey(), box); err == nil {
|
||||||
|
t.Error("открылся чужим ключом")
|
||||||
|
}
|
||||||
|
box[len(box)-1] ^= 1
|
||||||
|
if _, err := OpenRequest(c.Key, box); err == nil {
|
||||||
|
t.Error("открылся повреждённый")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приём переноса открывает новые порты в UFW, только если он активен, — как install.sh.
|
||||||
|
func TestOpenFirewall(t *testing.T) {
|
||||||
|
var calls []string
|
||||||
|
status := "Status: active"
|
||||||
|
defer func(f func(...string) ([]byte, error)) { ufw = f }(ufw)
|
||||||
|
ufw = func(args ...string) ([]byte, error) {
|
||||||
|
calls = append(calls, strings.Join(args, " "))
|
||||||
|
if args[0] == "status" {
|
||||||
|
return []byte(status), nil
|
||||||
|
}
|
||||||
|
if len(args) > 1 && args[1] == "45000/tcp" {
|
||||||
|
return nil, errors.New("exit 1")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
opened, failed := OpenFirewall(40222, 45000, 0)
|
||||||
|
if strings.Join(opened, ",") != "40222/tcp" || strings.Join(failed, ",") != "45000/tcp" {
|
||||||
|
t.Errorf("opened %v failed %v", opened, failed)
|
||||||
|
}
|
||||||
|
if strings.Join(calls, "; ") != "status; allow 40222/tcp; allow 45000/tcp; reload" {
|
||||||
|
t.Errorf("вызовы: %v", calls)
|
||||||
|
}
|
||||||
|
calls, status = nil, "Status: inactive"
|
||||||
|
if opened, _ := OpenFirewall(40222); opened != nil || len(calls) != 1 {
|
||||||
|
t.Errorf("UFW выключен, а правила трогаются: %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
|
||||||
|
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
|
||||||
|
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
|
||||||
|
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodePrefix = "awgmove://"
|
||||||
|
keyLen = 32
|
||||||
|
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
|
||||||
|
aadRequest = "awgmove-v1 request"
|
||||||
|
aadResponse = "awgmove-v1 response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
|
||||||
|
type Code struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKey() []byte {
|
||||||
|
k := make([]byte, keyLen)
|
||||||
|
rand.Read(k)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Code) String() string {
|
||||||
|
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
|
||||||
|
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseCode(s string) (Code, error) {
|
||||||
|
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if !strings.HasPrefix(s, CodePrefix) {
|
||||||
|
return Code{}, bad
|
||||||
|
}
|
||||||
|
raw, err := b64url.DecodeString(s[len(CodePrefix):])
|
||||||
|
if err != nil {
|
||||||
|
return Code{}, bad
|
||||||
|
}
|
||||||
|
v, err := jsjson.Decode(raw)
|
||||||
|
o, ok := v.(jsjson.Object)
|
||||||
|
if err != nil || !ok || o.Get2("v") != float64(Version) {
|
||||||
|
return Code{}, bad
|
||||||
|
}
|
||||||
|
host, _ := o.Get2("h").(string)
|
||||||
|
port, _ := o.Get2("p").(float64)
|
||||||
|
k, _ := o.Get2("k").(string)
|
||||||
|
key, err := b64url.DecodeString(k)
|
||||||
|
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
|
||||||
|
port != float64(int(port)) || err != nil || len(key) != keyLen {
|
||||||
|
return Code{}, bad
|
||||||
|
}
|
||||||
|
return Code{Host: host, Port: int(port), Key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL — куда слать архив.
|
||||||
|
func (c Code) URL() string {
|
||||||
|
h := c.Host
|
||||||
|
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
|
||||||
|
h = "[" + h + "]"
|
||||||
|
}
|
||||||
|
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
|
||||||
|
}
|
||||||
|
|
||||||
|
func gcm(key []byte) (cipher.AEAD, error) {
|
||||||
|
blk, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(blk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seal(key []byte, aad string, plain []byte) []byte {
|
||||||
|
g, err := gcm(key)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // ключ всегда 32 байта
|
||||||
|
}
|
||||||
|
nonce := make([]byte, g.NonceSize())
|
||||||
|
rand.Read(nonce)
|
||||||
|
return g.Seal(nonce, nonce, plain, []byte(aad))
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
|
||||||
|
|
||||||
|
func open(key []byte, aad string, box []byte) ([]byte, error) {
|
||||||
|
g, err := gcm(key)
|
||||||
|
if err != nil || len(box) < g.NonceSize() {
|
||||||
|
return nil, ErrSeal
|
||||||
|
}
|
||||||
|
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrSeal
|
||||||
|
}
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
|
||||||
|
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
|
||||||
|
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
|
||||||
|
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
|
||||||
|
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
|
||||||
|
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
|
||||||
|
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
|
||||||
|
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
|
||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dialTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Route — одна пересылка: локальный порт → адрес новой панели.
|
||||||
|
type Route struct {
|
||||||
|
Listen int
|
||||||
|
Target string // host:port
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
|
||||||
|
func ParseRoutes(s string) ([]Route, error) {
|
||||||
|
var out []Route
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l, t, ok := strings.Cut(part, "=")
|
||||||
|
port, err := strconv.Atoi(l)
|
||||||
|
if !ok || err != nil || port <= 0 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
|
||||||
|
}
|
||||||
|
host, tp, err := net.SplitHostPort(t)
|
||||||
|
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
|
||||||
|
return nil, fmt.Errorf("relay: неверный адрес %q", t)
|
||||||
|
}
|
||||||
|
out = append(out, Route{port, t})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyHeader — строка PROXY v1 для соединения c.
|
||||||
|
func proxyHeader(c net.Conn) string {
|
||||||
|
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
|
||||||
|
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return "PROXY UNKNOWN\r\n"
|
||||||
|
}
|
||||||
|
sip, dip := src.IP, dst.IP
|
||||||
|
fam := "TCP6"
|
||||||
|
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
|
||||||
|
fam, sip, dip = "TCP4", s4, d4
|
||||||
|
} else if s4 != nil || d4 != nil {
|
||||||
|
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
|
||||||
|
sip, dip = sip.To16(), dip.To16()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
|
||||||
|
func pipe(a, b net.Conn) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
cp := func(dst, src net.Conn) {
|
||||||
|
defer wg.Done()
|
||||||
|
io.Copy(dst, src)
|
||||||
|
if tc, ok := dst.(*net.TCPConn); ok {
|
||||||
|
tc.CloseWrite()
|
||||||
|
} else {
|
||||||
|
dst.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Add(2)
|
||||||
|
go cp(a, b)
|
||||||
|
go cp(b, a)
|
||||||
|
wg.Wait()
|
||||||
|
a.Close()
|
||||||
|
b.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
|
||||||
|
var d net.Dialer
|
||||||
|
d.Timeout = dialTimeout
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errorf("relay: :%d: %v", r.Listen, err)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
up, err := d.DialContext(ctx, "tcp", r.Target)
|
||||||
|
if err != nil {
|
||||||
|
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
|
||||||
|
c.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
|
||||||
|
c.Close()
|
||||||
|
up.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pipe(c, up)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
|
||||||
|
// значит, его ещё держит прежняя панель.
|
||||||
|
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
|
||||||
|
var lns []net.Listener
|
||||||
|
for _, r := range routes {
|
||||||
|
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
|
||||||
|
if err != nil {
|
||||||
|
for _, l := range lns {
|
||||||
|
l.Close()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
|
||||||
|
}
|
||||||
|
lns = append(lns, ln)
|
||||||
|
logf("relay: :%d → %s", r.Listen, r.Target)
|
||||||
|
}
|
||||||
|
for i, r := range routes {
|
||||||
|
go serve(ctx, lns[i], r, errorf)
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
for _, l := range lns {
|
||||||
|
l.Close()
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main — роль `awg-control relay`: пересылки из RELAY.
|
||||||
|
func Main(ctx context.Context) error {
|
||||||
|
routes, err := ParseRoutes(os.Getenv("RELAY"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
|
||||||
|
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
|
||||||
|
return Run(ctx, routes, logf, errorf)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRoutes(t *testing.T) {
|
||||||
|
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
|
||||||
|
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
|
||||||
|
t.Fatalf("ParseRoutes: %v %v", r, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
|
||||||
|
if _, err := ParseRoutes(bad); err == nil {
|
||||||
|
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
|
||||||
|
func TestRelayForwardsWithProxyHeader(t *testing.T) {
|
||||||
|
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
got := make(chan string, 2)
|
||||||
|
go func() {
|
||||||
|
c, err := target.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
r := bufio.NewReader(c)
|
||||||
|
hdr, _ := r.ReadString('\n')
|
||||||
|
got <- hdr
|
||||||
|
body, _ := io.ReadAll(r)
|
||||||
|
got <- string(body)
|
||||||
|
io.WriteString(c, "pong")
|
||||||
|
}()
|
||||||
|
|
||||||
|
port := freePort(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
|
||||||
|
}()
|
||||||
|
|
||||||
|
var cl net.Conn
|
||||||
|
for range 50 {
|
||||||
|
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
io.WriteString(cl, "ping")
|
||||||
|
cl.(*net.TCPConn).CloseWrite()
|
||||||
|
|
||||||
|
hdr := <-got
|
||||||
|
src := cl.LocalAddr().(*net.TCPAddr)
|
||||||
|
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
|
||||||
|
t.Errorf("заголовок %q, ожидали %q", hdr, want)
|
||||||
|
}
|
||||||
|
if body := <-got; body != "ping" {
|
||||||
|
t.Errorf("до панели дошло %q", body)
|
||||||
|
}
|
||||||
|
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
if back, _ := io.ReadAll(cl); string(back) != "pong" {
|
||||||
|
t.Errorf("обратно пришло %q", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
|
||||||
|
busy, _ := net.Listen("tcp", ":0")
|
||||||
|
defer busy.Close()
|
||||||
|
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "порт") {
|
||||||
|
t.Errorf("занятый порт: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-errs
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// «Серверы для всех»: add — у каждого живого ключа, remove — ни у кого, остальное не трогается;
|
||||||
|
// пиры снятых серверов — в надгробия, новые ключи получают набор по умолчанию.
|
||||||
|
func TestBulkServers(t *testing.T) {
|
||||||
|
p := newMovePanel(t, "off", "40500")
|
||||||
|
s, db := p.s, p.a.db
|
||||||
|
if err := p.a.initAPIKeys(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, q := range []string{
|
||||||
|
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'A', 'x', 1), (2, 'B', 'x', 1), (3, 'C', 'x', 1)",
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k1', x'01', 1), (2, 'u2', 'k2', x'02', 1), (3, 'u3', 'k3', x'03', 1)",
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (4, 'u4', 'k4', x'04', 1, 1)",
|
||||||
|
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 1), (2, 1), (2, 2), (3, 2), (4, 1)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 2, 'd', 'W', 'A', 1)",
|
||||||
|
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 1, 'P1', '10.0.0.2', 'K'), (5, 2, 'P2', '10.0.1.2', 'K')",
|
||||||
|
"INSERT INTO api_keys (id, label, key_hash, prefix, created_at) VALUES (1, 'Бот', 'h1', 'p1', 1), (2, 'Всё', 'h2', 'p2', 1)",
|
||||||
|
"INSERT INTO api_key_servers (key_id, server_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, 3)",
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(q); err != nil {
|
||||||
|
t.Fatal(q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers := func() string {
|
||||||
|
var out []string
|
||||||
|
for _, id := range []int64{1, 2, 3, 4} {
|
||||||
|
out = append(out, string(jsjson.MustMarshal(intsJSON(s.serverIDsOf(db, id)))))
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
body := func(kv ...any) jsjson.Object {
|
||||||
|
o := jsjson.Object{}
|
||||||
|
for i := 0; i < len(kv); i += 2 {
|
||||||
|
o = append(o, jsjson.KV{Key: kv[i].(string), Val: kv[i+1]})
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
f := func(ids ...float64) []any {
|
||||||
|
out := []any{}
|
||||||
|
for _, x := range ids {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
status := func(err error) int {
|
||||||
|
var ae *apiError
|
||||||
|
if errors.As(err, &ae) {
|
||||||
|
return ae.status
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := s.bulkServers(body("add", f(3), "remove", f(2), "dry_run", true))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := string(jsjson.MustMarshal(out)); got != `{"changed":3,"total":3,"hidden":[{"id":1,"label":"Бот","keys":3}]}` {
|
||||||
|
t.Errorf("предпросмотр: %s", got)
|
||||||
|
}
|
||||||
|
if got := servers(); got != "[1] [1,2] [2] [1]" {
|
||||||
|
t.Errorf("предпросмотр изменил ключи: %s", got)
|
||||||
|
}
|
||||||
|
if _, err := s.bulkServers(body("remove", f(1, 2))); status(err) != 409 {
|
||||||
|
t.Errorf("ключи без серверов: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.bulkServers(body("add", f(2), "remove", f(2))); status(err) != 400 {
|
||||||
|
t.Errorf("и у всех, и ни у кого: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.bulkServers(body("add", f(9))); status(err) != 400 {
|
||||||
|
t.Errorf("несуществующий сервер: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.bulkServers(body("add", f(3), "remove", f(2), "defaults", f(3))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := servers(); got != "[1,3] [1,3] [3] [1]" {
|
||||||
|
t.Errorf("после применения: %s (удаляемый ключ трогать нельзя)", got)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM peer_tombstones WHERE server_id = 2 AND pub_key = 'P2'").Scan(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Error("пир снятого сервера не в надгробиях")
|
||||||
|
}
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM device_peers WHERE device_id = 5 AND server_id = 3").Scan(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Error("устройство не получило заявку на новом сервере")
|
||||||
|
}
|
||||||
|
m, err := s.createMaster(body("label", "Новый"), nil)
|
||||||
|
if err != nil || string(jsjson.MustMarshal(m.Get2("servers"))) != "[3]" {
|
||||||
|
t.Errorf("новый ключ: %v %v", m, err)
|
||||||
|
}
|
||||||
|
if _, err := s.bulkServers(body("defaults", nil)); err != nil || s.defaultServers() != nil {
|
||||||
|
t.Errorf("сброс набора по умолчанию: %v %v", s.defaultServers(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
// Вкладка «Перенос»: панель переезжает с сервера «панель + VPN» на отдельную панель без VPN
|
||||||
|
// прямо из интерфейса, без архива в руках. Те же шаги, что `awg-control migrate` (общий код —
|
||||||
|
// internal/migrate), только архив идёт из панели в панель:
|
||||||
|
//
|
||||||
|
// новая: POST /ui/migrate/code → одноразовый код awgmove:// (адрес, порт панели, ключ)
|
||||||
|
// старая: POST /ui/migrate/export → замораживает /sub/v1, шлёт зашифрованный архив на
|
||||||
|
// POST /migrate/v1/import новой панели
|
||||||
|
// новая: раскладывает архив, заводит ноду #0, отвечает join-строкой и перезапускается
|
||||||
|
// старая: пишет PANEL_MOVED и запускает `awg-control join` — становится нодой #0 с ретранслятором
|
||||||
|
//
|
||||||
|
// Перезапуск и join делает CLI в отдельном процессе: панель останавливает сама себя.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/envfile"
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
"github.com/maeneko/forgetting/internal/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
moveCodeTTL = time.Hour
|
||||||
|
moveMaxFails = 5
|
||||||
|
moveMaxBundle = 1 << 30
|
||||||
|
moveTimeout = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Состояние переноса на этой панели (в памяти: перезапуск его сбрасывает, и так и надо).
|
||||||
|
const (
|
||||||
|
moveIdle = ""
|
||||||
|
moveSending = "sending" // старая: архив в пути, /sub/v1 заморожен
|
||||||
|
moveMoved = "moved" // старая: новая приняла, сейчас будет join
|
||||||
|
moveUncertain = "uncertain" // старая: архив ушёл, а ответа нет — новая могла его принять
|
||||||
|
moveImported = "imported" // новая: архив принят, сейчас перезапуск
|
||||||
|
)
|
||||||
|
|
||||||
|
type Migration struct {
|
||||||
|
a *App
|
||||||
|
envFile string // cli.env: сюда пишутся порты, PANEL_MOVED, RELAY_FROM
|
||||||
|
run func(args ...string) // `awg-control …` отдельным процессом; в тестах — заглушка
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
key []byte // новая: ключ действующего кода
|
||||||
|
host string
|
||||||
|
expires time.Time
|
||||||
|
fails int
|
||||||
|
state string
|
||||||
|
errText string
|
||||||
|
target string // старая: адрес новой панели
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMigration(a *App) *Migration {
|
||||||
|
m := &Migration{a: a, envFile: os.Getenv("CLI_ENV_FILE")}
|
||||||
|
m.run = m.spawn
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) filePath(env, name string) string {
|
||||||
|
if v := os.Getenv(env); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return filepath.Join(a.baseDir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// paths — где awg-ui держит файлы из архива (те же правила, что при их загрузке).
|
||||||
|
func (m *Migration) paths() migrate.Paths {
|
||||||
|
return migrate.PathsFrom(func(env string) string {
|
||||||
|
for _, f := range migrate.Files {
|
||||||
|
if f.Env == env {
|
||||||
|
return m.a.filePath(env, f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// canExport / canAccept — "" или причина, почему нельзя.
|
||||||
|
func (m *Migration) canExport() string {
|
||||||
|
switch {
|
||||||
|
case m.envFile == "":
|
||||||
|
return "Панель запущена не через awg-control — перенос недоступен"
|
||||||
|
case os.Getenv("CORE_HOST") != "":
|
||||||
|
return "Этот сервер уже нода другой панели"
|
||||||
|
case !m.a.nodes.local:
|
||||||
|
return "У этой панели нет своего VPN: переносить её как сервер-ноду нечего"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration) canAccept() string {
|
||||||
|
switch {
|
||||||
|
case m.envFile == "":
|
||||||
|
return "Панель запущена не через awg-control — перенос недоступен"
|
||||||
|
case m.a.nodes.local:
|
||||||
|
return "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
|
||||||
|
case !m.a.nodes.hubReady():
|
||||||
|
return "Не настроен приём нод (NODE_PORT): старый сервер подключится сюда нодой"
|
||||||
|
case migrate.Count(m.a.db) > 0:
|
||||||
|
return "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration) router() *Router {
|
||||||
|
rt := newRouter()
|
||||||
|
rt.Get("/", m.status)
|
||||||
|
rt.Post("/code", m.newCode)
|
||||||
|
rt.Delete("/code", func(c *Ctx) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.key = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
return m.status(c)
|
||||||
|
})
|
||||||
|
rt.Post("/export", m.export)
|
||||||
|
rt.Post("/cancel", m.cancel)
|
||||||
|
rt.Delete("/retire", func(c *Ctx) bool {
|
||||||
|
if err := envfile.Set(m.envFile, nil, "MOVED_IN_AT"); err != nil {
|
||||||
|
return c.Error(500, err.Error())
|
||||||
|
}
|
||||||
|
os.Unsetenv("MOVED_IN_AT")
|
||||||
|
return m.status(c)
|
||||||
|
})
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration) status(c *Ctx) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
state, errText, target := m.state, m.errText, m.target
|
||||||
|
var code any
|
||||||
|
if m.key != nil && time.Now().Before(m.expires) {
|
||||||
|
code = jsjson.Object{{Key: "code", Val: migrate.Code{Host: m.host, Port: m.a.port, Key: m.key}.String()},
|
||||||
|
{Key: "expires_in", Val: int(time.Until(m.expires).Seconds())}}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
o := jsjson.Object{{Key: "state", Val: state}, {Key: "error", Val: orNil(errText)}, {Key: "target", Val: orNil(target)}, {Key: "code", Val: code}}
|
||||||
|
// После приёма база закрыта до перезапуска — что можно, уже не считаем.
|
||||||
|
if state != moveImported {
|
||||||
|
o = append(o, jsjson.KV{Key: "export", Val: orNil(m.canExport())}, jsjson.KV{Key: "accept", Val: orNil(m.canAccept())},
|
||||||
|
jsjson.KV{Key: "retire", Val: m.retire()})
|
||||||
|
}
|
||||||
|
return c.JSON(200, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Новая панель ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// retire — можно ли выключить старый сервер (ноду #0 с ретранслятором): кто из клиентов и нод
|
||||||
|
// уже знает новый адрес панели. Клиент узнаёт его из endpoints любого конфига после переноса,
|
||||||
|
// нода — из ready (core_ok). Ссылки sen:// без устройств ещё никем не открыты: в них старый
|
||||||
|
// адрес, их выдают заново. nil — панель ничего не принимала (или отметку убрали).
|
||||||
|
func (m *Migration) retire() any {
|
||||||
|
since, _ := strconv.ParseInt(os.Getenv("MOVED_IN_AT"), 10, 64)
|
||||||
|
if since == 0 || m.a.nodes.local {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
home := m.a.nodes.home()
|
||||||
|
var total, fresh, unused int
|
||||||
|
m.a.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(d.last_seen >= ?), 0) FROM devices d
|
||||||
|
JOIN master_keys k ON k.id = d.master_id WHERE k.revoked_at IS NULL`, since).Scan(&total, &fresh)
|
||||||
|
m.a.db.QueryRow(`SELECT COUNT(*) FROM master_keys k WHERE k.revoked_at IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.master_id = k.id)`).Scan(&unused)
|
||||||
|
nodes := []any{}
|
||||||
|
if rows, err := m.a.db.Query("SELECT id, name, core_ack FROM nodes WHERE id != 0 AND pub IS NOT NULL ORDER BY id"); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name string
|
||||||
|
var ack sql.NullString
|
||||||
|
if rows.Scan(&id, &name, &ack) == nil {
|
||||||
|
nodes = append(nodes, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name},
|
||||||
|
{Key: "online", Val: m.a.nodes.IsOnline(int(id))}, {Key: "ok", Val: home != "" && ack.String == home}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
return jsjson.Object{{Key: "since", Val: since}, {Key: "home", Val: orNil(home)},
|
||||||
|
{Key: "old", Val: jsjson.Object{{Key: "exists", Val: m.a.nodes.Exists(0)}, {Key: "online", Val: m.a.nodes.IsOnline(0)}}},
|
||||||
|
{Key: "devices", Val: jsjson.Object{{Key: "total", Val: total}, {Key: "fresh", Val: fresh}}},
|
||||||
|
{Key: "unused_links", Val: unused}, {Key: "nodes", Val: nodes}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration) newCode(c *Ctx) bool {
|
||||||
|
if why := m.canAccept(); why != "" {
|
||||||
|
return c.Error(409, why)
|
||||||
|
}
|
||||||
|
host, _ := c.Obj().Get2("host").(string)
|
||||||
|
if h := strings.TrimSpace(os.Getenv("SUB_HOST")); h != "" {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
if !reHost.MatchString(host) {
|
||||||
|
return c.Error(400, "Не удалось определить адрес панели — задайте SUB_HOST")
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.key, m.host, m.expires, m.fails = migrate.NewKey(), host, time.Now().Add(moveCodeTTL), 0
|
||||||
|
m.mu.Unlock()
|
||||||
|
logf("migrate: выдан код переноса (действует час)")
|
||||||
|
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 {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.key == nil || time.Now().After(m.expires) {
|
||||||
|
return c.Error(404, "Новая панель не ждёт переноса: выдайте код заново")
|
||||||
|
}
|
||||||
|
box, err := io.ReadAll(http.MaxBytesReader(c.W, c.R.Body, moveMaxBundle))
|
||||||
|
if err != nil {
|
||||||
|
return c.Error(413, "Архив слишком большой")
|
||||||
|
}
|
||||||
|
plain, err := migrate.OpenRequest(m.key, box)
|
||||||
|
if err != nil {
|
||||||
|
if m.fails++; m.fails >= moveMaxFails {
|
||||||
|
m.key = nil
|
||||||
|
}
|
||||||
|
warnf("migrate: архив не расшифрован ip=%s", c.remoteIP())
|
||||||
|
return c.Error(403, "Код не подходит")
|
||||||
|
}
|
||||||
|
key := m.key
|
||||||
|
m.key = nil // код одноразовый
|
||||||
|
if why := m.canAccept(); why != "" {
|
||||||
|
return c.Error(409, why)
|
||||||
|
}
|
||||||
|
b, err := migrate.Read(bytes.NewReader(plain))
|
||||||
|
if err != nil {
|
||||||
|
return c.Error(400, err.Error())
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
host := m.host
|
||||||
|
plan, err := migrate.NewPlan(b, func(k string) int {
|
||||||
|
switch k {
|
||||||
|
case "SUB_PORT":
|
||||||
|
return m.a.sub.port
|
||||||
|
case "NODE_PORT":
|
||||||
|
return m.a.nodes.port
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}, m.a.port, host)
|
||||||
|
if err != nil {
|
||||||
|
return c.Error(409, err.Error())
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(c.remoteIP()); ip != nil {
|
||||||
|
plan.TrustRelay(ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дальше файлы меняются: база закрывается (WAL сливается в файл до того, как его отложат).
|
||||||
|
paths := m.paths()
|
||||||
|
fail := func(err error) bool {
|
||||||
|
m.state, m.errText = moveImported, "Перенос прерван: "+err.Error()+". Прежние файлы — в *.bak-<время>."
|
||||||
|
warnf("migrate: %v", err)
|
||||||
|
return c.Error(500, m.errText)
|
||||||
|
}
|
||||||
|
m.a.db.Close()
|
||||||
|
secret := migrate.NewKey()[:16]
|
||||||
|
if err := migrate.Install(b, paths); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
pin, err := migrate.CertPin(paths["node_tls.crt"])
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("сертификат хаба: %w", err))
|
||||||
|
}
|
||||||
|
if err := envfile.Set(m.envFile, plan.Env); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
// Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh.
|
||||||
|
if opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort); len(opened)+len(failed) > 0 {
|
||||||
|
logf("migrate: UFW открыт %v, не открылись %v", opened, failed)
|
||||||
|
}
|
||||||
|
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
|
||||||
|
m.state = moveImported
|
||||||
|
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
|
||||||
|
m.run("restart")
|
||||||
|
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
|
||||||
|
c.W.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
c.W.WriteHeader(200)
|
||||||
|
c.W.Write(migrate.SealResponse(key, resp))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Старая панель ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (m *Migration) export(c *Ctx) bool {
|
||||||
|
if why := m.canExport(); why != "" {
|
||||||
|
return c.Error(409, why)
|
||||||
|
}
|
||||||
|
codeStr, _ := c.Obj().Get2("code").(string)
|
||||||
|
code, err := migrate.ParseCode(codeStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Error(400, err.Error())
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.state != moveIdle {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return c.Error(409, "Перенос уже идёт")
|
||||||
|
}
|
||||||
|
m.state, m.errText = moveSending, ""
|
||||||
|
m.mu.Unlock()
|
||||||
|
end := func(state, errText string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state, m.errText = state, errText
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.a.sub.frozen.Store(state != moveIdle)
|
||||||
|
}
|
||||||
|
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
|
||||||
|
// регистрация или смена ключа сейчас остались бы только в старой базе.
|
||||||
|
m.a.sub.frozen.Store(true)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
|
||||||
|
end(moveIdle, "")
|
||||||
|
return c.Error(500, "Архив не собран: "+err.Error())
|
||||||
|
}
|
||||||
|
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
|
||||||
|
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
|
||||||
|
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
|
||||||
|
if err != nil {
|
||||||
|
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
|
||||||
|
var op *net.OpError
|
||||||
|
if errors.As(err, &op) && op.Op == "dial" {
|
||||||
|
end(moveIdle, "")
|
||||||
|
return c.Error(502, "Новая панель не отвечает: "+err.Error())
|
||||||
|
}
|
||||||
|
return m.uncertain(c, end, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
end(moveIdle, "")
|
||||||
|
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
|
||||||
|
if v, derr := jsjson.Decode(raw); derr == nil {
|
||||||
|
if e, ok := field(v, "error").(string); ok {
|
||||||
|
msg = "Новая панель: " + e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Error(502, msg)
|
||||||
|
}
|
||||||
|
var plain []byte
|
||||||
|
if err == nil {
|
||||||
|
plain, err = migrate.OpenResponse(code.Key, raw)
|
||||||
|
}
|
||||||
|
var join string
|
||||||
|
if err == nil {
|
||||||
|
v, derr := jsjson.Decode(plain)
|
||||||
|
join, _ = field(v, "join").(string)
|
||||||
|
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
|
||||||
|
err = errors.New("ответ без join-строки")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return m.uncertain(c, end, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
|
||||||
|
return m.uncertain(c, end, err)
|
||||||
|
}
|
||||||
|
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
|
||||||
|
m.mu.Lock()
|
||||||
|
m.target = target
|
||||||
|
m.mu.Unlock()
|
||||||
|
end(moveMoved, "")
|
||||||
|
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
|
||||||
|
m.run("join", join)
|
||||||
|
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
|
||||||
|
// панель его приняла, вторая живая копия базы разошлась бы с ней.
|
||||||
|
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
|
||||||
|
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
|
||||||
|
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
|
||||||
|
end(moveUncertain, msg)
|
||||||
|
warnf("migrate: %s", msg)
|
||||||
|
return c.Error(502, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration) cancel(c *Ctx) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
ok := m.state == moveUncertain
|
||||||
|
if ok {
|
||||||
|
m.state, m.errText = moveIdle, ""
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return c.Error(409, "Отменять нечего")
|
||||||
|
}
|
||||||
|
m.a.sub.frozen.Store(false)
|
||||||
|
logf("migrate: перенос отменён — подписка снова работает")
|
||||||
|
return m.status(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration) exportMeta() jsjson.Object {
|
||||||
|
name, ip := "VPN", os.Getenv("SERVER_IP")
|
||||||
|
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
|
||||||
|
if s, ok := field(r.Data, "server").(string); ok && s != "" {
|
||||||
|
name = s
|
||||||
|
}
|
||||||
|
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
|
||||||
|
ip = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 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: "ui_prefix", Val: os.Getenv("UI_PREFIX")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
|
||||||
|
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
|
||||||
|
func (m *Migration) spawn(args ...string) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
warnf("migrate: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var env []string
|
||||||
|
for _, kv := range os.Environ() {
|
||||||
|
k, _, _ := strings.Cut(kv, "=")
|
||||||
|
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
|
||||||
|
env = append(env, kv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
cmd := exec.Command(exe, args...)
|
||||||
|
cmd.Env, cmd.Dir = env, "/"
|
||||||
|
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
|
||||||
|
cmd.Stdout, cmd.Stderr = f, f
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
warnf("migrate: awg-control %s: %v", args[0], err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Wait()
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testApp(t *testing.T, dir, local string) *App {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("LOCAL_NODE", local)
|
||||||
|
t.Setenv("NODE_PORT", "")
|
||||||
|
db, err := openUIDB(filepath.Join(dir, "ui.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
a := &App{db: db, baseDir: dir}
|
||||||
|
if a.nodes, err = newNodes(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
|
||||||
|
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
|
||||||
|
// туннель, а сервис на macOS — ещё и ключ.
|
||||||
|
func TestMigratedConfigIsByteIdentical(t *testing.T) {
|
||||||
|
oldDir, newDir := t.TempDir(), t.TempDir()
|
||||||
|
old := testApp(t, oldDir, "on")
|
||||||
|
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
|
||||||
|
if err := s.initSchema(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, q := range []string{
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
|
||||||
|
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
|
||||||
|
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
|
||||||
|
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
|
||||||
|
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
|
||||||
|
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
|
||||||
|
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
|
||||||
|
} {
|
||||||
|
if _, err := old.db.Exec(q); err != nil {
|
||||||
|
t.Fatalf("%s: %v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dev := s.deviceByID(old.db, 5)
|
||||||
|
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
|
||||||
|
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
|
||||||
|
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Как migrate import: нода #0 ещё не подключалась.
|
||||||
|
pre := testApp(t, newDir, "off")
|
||||||
|
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
moved := testApp(t, newDir, "off")
|
||||||
|
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
|
||||||
|
if err := s2.initSchema(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s2.panelID != s.panelID {
|
||||||
|
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
|
||||||
|
}
|
||||||
|
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
|
||||||
|
if string(before) != string(after) {
|
||||||
|
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
|
||||||
|
}
|
||||||
|
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
|
||||||
|
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
|
||||||
|
}
|
||||||
|
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
|
||||||
|
t.Errorf("IDs = %v", ids)
|
||||||
|
}
|
||||||
|
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
|
||||||
|
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
|
||||||
|
}
|
||||||
|
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
|
||||||
|
t.Errorf("List: %+v", l)
|
||||||
|
}
|
||||||
|
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
|
||||||
|
s2.host = ""
|
||||||
|
if h := s2.subHost(nil); h != "" {
|
||||||
|
t.Errorf("subHost = %q, ожидали пусто", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
|
||||||
|
t.Setenv("LOCAL_NODE", "on")
|
||||||
|
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
|
||||||
|
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProxyV1(t *testing.T) {
|
||||||
|
for line, want := range map[string]string{
|
||||||
|
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
|
||||||
|
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
|
||||||
|
"PROXY UNKNOWN": "unknown",
|
||||||
|
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
|
||||||
|
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
|
||||||
|
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
|
||||||
|
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
|
||||||
|
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
|
||||||
|
"GET / HTTP/1.1": "",
|
||||||
|
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
|
||||||
|
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
|
||||||
|
} {
|
||||||
|
addr, ok := parseProxyV1(line)
|
||||||
|
got := ""
|
||||||
|
switch {
|
||||||
|
case ok && addr == nil:
|
||||||
|
got = "unknown"
|
||||||
|
case ok:
|
||||||
|
got = addr.String()
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%q → %q, ожидали %q", line, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
|
||||||
|
// есть, а от остальных заголовок остаётся байтами потока.
|
||||||
|
func TestProxyListener(t *testing.T) {
|
||||||
|
try := func(trusted bool, send string) (remote, data string) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
|
||||||
|
cl, err := net.Dial("tcp", raw.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
io.WriteString(cl, send)
|
||||||
|
cl.(*net.TCPConn).CloseWrite()
|
||||||
|
done := make(chan net.Conn, 1)
|
||||||
|
go func() { c, _ := ln.Accept(); done <- c }()
|
||||||
|
select {
|
||||||
|
case c := <-done:
|
||||||
|
defer c.Close()
|
||||||
|
b, _ := io.ReadAll(bufio.NewReader(c))
|
||||||
|
return c.RemoteAddr().String(), string(b)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("Accept не вернулся")
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
|
||||||
|
t.Errorf("доверенный с заголовком: %s %q", r, d)
|
||||||
|
}
|
||||||
|
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
|
||||||
|
t.Errorf("доверенный без заголовка: %s %q", r, d)
|
||||||
|
}
|
||||||
|
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
|
||||||
|
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
|
||||||
|
t.Errorf("чужой с заголовком: %s %q", r, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
"github.com/maeneko/forgetting/internal/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type movePanel struct {
|
||||||
|
a *App
|
||||||
|
s *Sub
|
||||||
|
env string
|
||||||
|
calls [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LOCAL_NODE", local)
|
||||||
|
t.Setenv("NODE_PORT", nodePort)
|
||||||
|
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
|
||||||
|
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
|
||||||
|
db, err := openUIDB(filepath.Join(dir, "ui.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
a := &App{db: db, baseDir: dir, port: 34001}
|
||||||
|
if a.nodes, err = newNodes(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
|
||||||
|
if err := a.sub.initSchema(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
|
||||||
|
a.migration = newMigration(a)
|
||||||
|
a.migration.envFile = p.env
|
||||||
|
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
|
||||||
|
v, _ := jsjson.Decode(w.Body.Bytes())
|
||||||
|
o, _ := v.(jsjson.Object)
|
||||||
|
return w.Code, o
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
|
||||||
|
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
|
||||||
|
func TestMigrateBetweenPanels(t *testing.T) {
|
||||||
|
old := newMovePanel(t, "on", "")
|
||||||
|
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
|
||||||
|
for _, q := range []string{
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
|
||||||
|
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
|
||||||
|
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
|
||||||
|
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
|
||||||
|
} {
|
||||||
|
if _, err := old.a.db.Exec(q); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
|
||||||
|
|
||||||
|
fresh := newMovePanel(t, "off", "40500")
|
||||||
|
m := fresh.a.migration
|
||||||
|
if why := m.canAccept(); why != "" {
|
||||||
|
t.Fatalf("чистая панель без VPN не принимает: %s", why)
|
||||||
|
}
|
||||||
|
if why := old.a.migration.canAccept(); why == "" {
|
||||||
|
t.Error("панель со своим VPN принимает перенос")
|
||||||
|
}
|
||||||
|
if why := fresh.a.migration.canExport(); why == "" {
|
||||||
|
t.Error("панель без VPN отдаёт перенос")
|
||||||
|
}
|
||||||
|
rt := newRouter()
|
||||||
|
rt.Post("/migrate/v1/import", m.importHandler)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, raw, _, _ := readBody(r, rt.bodyLimit)
|
||||||
|
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
port, _ := strconv.Atoi(u.Port())
|
||||||
|
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
|
||||||
|
|
||||||
|
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
|
||||||
|
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
|
||||||
|
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
|
||||||
|
t.Fatalf("чужой код: %d %v", st, o)
|
||||||
|
}
|
||||||
|
if old.s.frozen.Load() || m.key == nil {
|
||||||
|
t.Fatal("после отказа подписка заморожена или код сгорел")
|
||||||
|
}
|
||||||
|
|
||||||
|
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
|
||||||
|
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
|
||||||
|
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
|
||||||
|
t.Fatalf("export: %d %v", st, o)
|
||||||
|
}
|
||||||
|
if !old.s.frozen.Load() {
|
||||||
|
t.Error("старая подписка не заморожена до join")
|
||||||
|
}
|
||||||
|
if len(old.calls) != 1 || old.calls[0][0] != "join" {
|
||||||
|
t.Fatalf("старая панель: %v", old.calls)
|
||||||
|
}
|
||||||
|
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
|
||||||
|
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
|
||||||
|
t.Errorf("join: %s", jsjson.MustMarshal(j))
|
||||||
|
}
|
||||||
|
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
|
||||||
|
t.Errorf("старый cli.env: %q", raw)
|
||||||
|
}
|
||||||
|
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
|
||||||
|
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile(fresh.env)
|
||||||
|
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
|
||||||
|
if !strings.Contains(string(raw), want) {
|
||||||
|
t.Errorf("новый cli.env без %s: %q", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
|
||||||
|
t.Errorf("ключ подписи: %q", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
|
||||||
|
t.Setenv("NODE_PORT", "")
|
||||||
|
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
moved := &App{db: db, baseDir: fresh.a.baseDir}
|
||||||
|
if moved.nodes, err = newNodes(moved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
|
||||||
|
if err := s2.initSchema(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
|
||||||
|
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
|
||||||
|
}
|
||||||
|
if !moved.nodes.Exists(0) {
|
||||||
|
t.Error("нет ноды #0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный приём: кода уже нет.
|
||||||
|
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
|
||||||
|
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
|
||||||
|
t.Errorf("второй перенос тем же кодом: %d", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
-39
@@ -2,10 +2,12 @@ package ui
|
|||||||
|
|
||||||
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
|
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
|
||||||
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
|
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
|
||||||
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
// POST /ui/monitor/install — попросить awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
||||||
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
|
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
|
||||||
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
|
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
|
||||||
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
|
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
|
||||||
|
// Prometheus — на этой машине или на ноде (панель без VPN, например после переноса): тогда и
|
||||||
|
// запросы к нему, и его скрейп метрик панели идут через агента ноды по её WSS.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -80,8 +82,22 @@ type Monitor struct {
|
|||||||
promURL string
|
promURL string
|
||||||
localEnabled bool
|
localEnabled bool
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
|
||||||
|
srcMu sync.Mutex
|
||||||
|
src *promSource // найденный Prometheus; nil — ещё не искали или не нашли
|
||||||
|
srcAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promSource — где Prometheus: local — на этой машине, иначе на ноде node (через её агента).
|
||||||
|
type promSource struct {
|
||||||
|
local bool
|
||||||
|
node int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Найденный Prometheus помним недолго; ненайденный ищем при каждом запросе (вкладка
|
||||||
|
// запрашивает статус редко, а во время установки должна увидеть его сразу).
|
||||||
|
const promSourceTTL = 30 * time.Second
|
||||||
|
|
||||||
func newMonitor(a *App) *Monitor {
|
func newMonitor(a *App) *Monitor {
|
||||||
return &Monitor{
|
return &Monitor{
|
||||||
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
|
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
|
||||||
@@ -92,14 +108,72 @@ func newMonitor(a *App) *Monitor {
|
|||||||
|
|
||||||
var errProm = errors.New("Prometheus не отвечает")
|
var errProm = errors.New("Prometheus не отвечает")
|
||||||
|
|
||||||
func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) {
|
// source — свой Prometheus, если он есть; иначе первый по id сервер, чей агент отдаёт свой.
|
||||||
q := url.Values{}
|
func (m *Monitor) source() *promSource {
|
||||||
q.Set("query", query)
|
m.srcMu.Lock()
|
||||||
q.Set("start", strconv.FormatInt(start, 10))
|
if m.src != nil && time.Since(m.srcAt) < promSourceTTL {
|
||||||
q.Set("end", strconv.FormatInt(end, 10))
|
s := m.src
|
||||||
q.Set("step", strconv.FormatInt(step, 10))
|
m.srcMu.Unlock()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
m.srcMu.Unlock()
|
||||||
|
s := m.findSource()
|
||||||
|
m.srcMu.Lock()
|
||||||
|
m.src, m.srcAt = s, time.Now()
|
||||||
|
m.srcMu.Unlock()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetSource — Prometheus поставили, удалили или он перестал отвечать: искать заново.
|
||||||
|
func (m *Monitor) forgetSource() {
|
||||||
|
m.srcMu.Lock()
|
||||||
|
m.src = nil
|
||||||
|
m.srcMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) findSource() *promSource {
|
||||||
|
if m.promReady() {
|
||||||
|
return &promSource{local: true}
|
||||||
|
}
|
||||||
|
ids := m.a.nodes.IDs()
|
||||||
|
ready := make([]bool, len(ids))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, id := range ids {
|
||||||
|
// Сервер 0 со своим awg-ctrl — эта же машина: её Prometheus уже проверен выше.
|
||||||
|
if id == 0 && m.localEnabled || !m.a.nodes.IsOnline(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
r, err := m.a.nodes.CtrlFor(id)("GET", "/prom/-/ready", nil)
|
||||||
|
ready[i] = err == nil && r.Status == 200
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, id := range ids {
|
||||||
|
if ready[i] {
|
||||||
|
return &promSource{node: id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promGet — JSON-ответ Prometheus (path с запросом): напрямую или через агента ноды.
|
||||||
|
func (m *Monitor) promGet(src *promSource, path string, q url.Values) (any, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, errProm
|
||||||
|
}
|
||||||
|
path += "?" + q.Encode()
|
||||||
|
if !src.local {
|
||||||
|
r, err := m.a.nodes.CtrlFor(src.node)("GET", "/prom"+path, nil)
|
||||||
|
if err != nil || r.Status < 200 || r.Status >= 300 {
|
||||||
|
return nil, errProm
|
||||||
|
}
|
||||||
|
return r.Data, nil
|
||||||
|
}
|
||||||
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
||||||
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
|
resp, err := c.Get(m.promURL + path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errProm
|
return nil, errProm
|
||||||
}
|
}
|
||||||
@@ -108,8 +182,21 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
|
|||||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return nil, errProm
|
return nil, errProm
|
||||||
}
|
}
|
||||||
|
return decodeLikeAxios(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) rangeQuery(src *promSource, query string, start, end, step int64) (*series, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("query", query)
|
||||||
|
q.Set("start", strconv.FormatInt(start, 10))
|
||||||
|
q.Set("end", strconv.FormatInt(end, 10))
|
||||||
|
q.Set("step", strconv.FormatInt(step, 10))
|
||||||
|
data, err := m.promGet(src, "/api/v1/query_range", q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out := &series{m: map[int]*seriesOne{}}
|
out := &series{m: map[int]*seriesOne{}}
|
||||||
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
|
for _, r := range arr(field(data, "data", "result")) {
|
||||||
metric := field(r, "metric")
|
metric := field(r, "metric")
|
||||||
idf := jsToNumberAny(field(metric, "server_id"))
|
idf := jsToNumberAny(field(metric, "server_id"))
|
||||||
if field(metric, "server_id") == nil {
|
if field(metric, "server_id") == nil {
|
||||||
@@ -146,22 +233,16 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// instantQuery — значение на момент time по каждому server_id.
|
// instantQuery — значение на момент time по каждому server_id.
|
||||||
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
|
func (m *Monitor) instantQuery(src *promSource, query string, time_ int64) (map[int]float64, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("query", query)
|
q.Set("query", query)
|
||||||
q.Set("time", strconv.FormatInt(time_, 10))
|
q.Set("time", strconv.FormatInt(time_, 10))
|
||||||
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
data, err := m.promGet(src, "/api/v1/query", q)
|
||||||
resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errProm
|
return nil, err
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, errProm
|
|
||||||
}
|
}
|
||||||
out := map[int]float64{}
|
out := map[int]float64{}
|
||||||
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
|
for _, r := range arr(field(data, "data", "result")) {
|
||||||
metric := field(r, "metric")
|
metric := field(r, "metric")
|
||||||
if field(metric, "server_id") == nil {
|
if field(metric, "server_id") == nil {
|
||||||
continue
|
continue
|
||||||
@@ -228,12 +309,43 @@ type monitorJob struct {
|
|||||||
log string
|
log string
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
|
// target — чей awg-ctrl ставит и удаляет Prometheus: того сервера, где он уже стоит; если его
|
||||||
func (m *Monitor) jobState() monitorJob {
|
// нет — свой (панель + VPN) или выбранный в сервер-баре (панель без VPN). -1 — некому: например,
|
||||||
if !m.localEnabled {
|
// Prometheus на машине панели без VPN поставлен руками.
|
||||||
|
func (m *Monitor) target(src *promSource, qs url.Values) int {
|
||||||
|
switch {
|
||||||
|
case src != nil && !src.local:
|
||||||
|
return src.node
|
||||||
|
case m.localEnabled:
|
||||||
|
return 0
|
||||||
|
case src != nil:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) && f >= 0 && m.a.nodes.Exists(int(f)) {
|
||||||
|
return int(f)
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// host — сервер target, если это не машина панели (её вкладка так и называет: «этот сервер»).
|
||||||
|
func (m *Monitor) host(target int) any {
|
||||||
|
if target < 0 || target == 0 && m.localEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, s := range m.a.nodes.List() {
|
||||||
|
if s.ID == target {
|
||||||
|
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobState — установка возможна, только если у target есть awg-ctrl и он знает маршрут.
|
||||||
|
func (m *Monitor) jobState(target int) monitorJob {
|
||||||
|
if target < 0 {
|
||||||
return monitorJob{}
|
return monitorJob{}
|
||||||
}
|
}
|
||||||
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
|
r, err := m.a.nodes.CtrlFor(target)("GET", "/api/monitor/job", nil)
|
||||||
if err != nil || r.Status != 200 {
|
if err != nil || r.Status != 200 {
|
||||||
return monitorJob{}
|
return monitorJob{}
|
||||||
}
|
}
|
||||||
@@ -251,27 +363,26 @@ func (m *Monitor) jobState() monitorJob {
|
|||||||
func (m *Monitor) router() *Router {
|
func (m *Monitor) router() *Router {
|
||||||
rt := newRouter()
|
rt := newRouter()
|
||||||
rt.Get("/", func(c *Ctx) bool {
|
rt.Get("/", func(c *Ctx) bool {
|
||||||
var ready bool
|
src := m.source()
|
||||||
var job monitorJob
|
ready := src != nil
|
||||||
var wg sync.WaitGroup
|
target := m.target(src, c.R.URL.Query())
|
||||||
wg.Add(2)
|
job := m.jobState(target)
|
||||||
go func() { defer wg.Done(); ready = m.promReady() }()
|
|
||||||
go func() { defer wg.Done(); job = m.jobState() }()
|
|
||||||
wg.Wait()
|
|
||||||
var jobOut any
|
var jobOut any
|
||||||
if job.can && truthy(job.action) {
|
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}}
|
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},
|
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: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}})
|
||||||
})
|
})
|
||||||
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
|
// Установка и удаление — одно и то же: просим awg-ctrl сервера, он запускает CLI от root.
|
||||||
ask := func(action string) handler {
|
ask := func(action string) handler {
|
||||||
return func(c *Ctx) bool {
|
return func(c *Ctx) bool {
|
||||||
if !m.localEnabled {
|
target := m.target(m.source(), c.R.URL.Query())
|
||||||
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
|
if target < 0 {
|
||||||
|
return c.Error(409, "Некому поставить Prometheus: выберите VPN-сервер или выполните команду на машине")
|
||||||
}
|
}
|
||||||
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
|
m.forgetSource()
|
||||||
|
r, err := m.a.nodes.CtrlFor(target)("POST", "/api/monitor/"+action, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Error(502, "awg-ctrl недоступен")
|
return c.Error(502, "awg-ctrl недоступен")
|
||||||
}
|
}
|
||||||
@@ -294,6 +405,7 @@ func (m *Monitor) router() *Router {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
|
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
|
||||||
}
|
}
|
||||||
|
src := m.source()
|
||||||
end := now()
|
end := now()
|
||||||
start := end - rg.seconds
|
start := end - rg.seconds
|
||||||
window := promRateWindow
|
window := promRateWindow
|
||||||
@@ -310,18 +422,21 @@ func (m *Monitor) router() *Router {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, pq := range promQueries {
|
for i, pq := range promQueries {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }()
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
got[i], errs[i] = m.rangeQuery(src, withWindow(pq.q, window), start, end, rg.step)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
span := strconv.FormatInt(rg.seconds, 10) + "s"
|
span := strconv.FormatInt(rg.seconds, 10) + "s"
|
||||||
n := len(promQueries)
|
n := len(promQueries)
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
|
usedDown, errs[n] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
|
usedUp, errs[n+1] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
|
||||||
}()
|
}()
|
||||||
if window != promRateWindow {
|
if window != promRateWindow {
|
||||||
for j, k := range rated {
|
for j, k := range rated {
|
||||||
@@ -332,7 +447,7 @@ func (m *Monitor) router() *Router {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
v, err := m.instantQuery(pq.q, end)
|
v, err := m.instantQuery(src, pq.q, end)
|
||||||
errs[n+2+j] = err
|
errs[n+2+j] = err
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
nowVals[k] = v
|
nowVals[k] = v
|
||||||
@@ -344,6 +459,7 @@ func (m *Monitor) router() *Router {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
for _, err := range errs {
|
for _, err := range errs {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
m.forgetSource()
|
||||||
return c.Error(502, err.Error())
|
return c.Error(502, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Панель без VPN, Prometheus — на ноде #0 (сервер, с которого панель переехала): вкладка
|
||||||
|
// читает его через агента, а сам он скрейпит метрики панели у агента, тот — у core.
|
||||||
|
func TestPrometheusOnNodeZero(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("openssl"); err != nil {
|
||||||
|
t.Skip("нет openssl для сертификата хаба")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
hubPort := freePort(t)
|
||||||
|
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
|
||||||
|
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
|
||||||
|
a := testApp(t, dir, "off")
|
||||||
|
t.Setenv("NODE_PORT", strconv.Itoa(hubPort))
|
||||||
|
var err error
|
||||||
|
if a.nodes, err = newNodes(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.metrics, err = newMetrics(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
der, _ := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
keyFile := filepath.Join(dir, "node.key")
|
||||||
|
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
|
||||||
|
if _, err := a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'VPN', ?, 1)", b64url.EncodeToString(pub)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/metrics":
|
||||||
|
io.WriteString(w, `{"up":true,"peers":{"total":2,"online":1}}`)
|
||||||
|
case "/health":
|
||||||
|
io.WriteString(w, `{"status":"ok","server":"VPN"}`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(404)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ctrl.Close()
|
||||||
|
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/-/ready":
|
||||||
|
io.WriteString(w, "Prometheus Server is Ready.\n")
|
||||||
|
case "/api/v1/query_range":
|
||||||
|
io.WriteString(w, `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"server":"VPN","server_id":"0"},"values":[[100,"3"],[115,"4"]]}]}}`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(400)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer prom.Close()
|
||||||
|
|
||||||
|
a.nodes.Start()
|
||||||
|
metricsPort := freePort(t)
|
||||||
|
hashFile := filepath.Join(dir, "monitor_token.sha256")
|
||||||
|
sum := sha256.Sum256([]byte("awgm_node"))
|
||||||
|
os.WriteFile(hashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
|
||||||
|
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, Version: MinNodeVersion,
|
||||||
|
Logf: t.Logf, Errorf: t.Logf,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go ag.Run(ctx)
|
||||||
|
for !a.nodes.IsOnline(0) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
t.Fatal("нода #0 не подключилась")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newMonitor(a)
|
||||||
|
m.promURL = "http://127.0.0.1:1" // своего Prometheus у панели нет
|
||||||
|
src := m.source()
|
||||||
|
if src == nil || src.local || src.node != 0 {
|
||||||
|
t.Fatalf("Prometheus не найден на ноде #0: %+v", src)
|
||||||
|
}
|
||||||
|
s, err := m.rangeQuery(src, "awg_peers_online", 100, 115, 15)
|
||||||
|
if err != nil || len(s.ids) != 1 || s.m[0].name != "VPN" || len(s.m[0].points) != 2 || s.m[0].points[1][1] != 4 {
|
||||||
|
t.Fatalf("query_range через агента: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
if h := m.host(m.target(src, nil)); h == nil {
|
||||||
|
t.Error("вкладка не знает, на каком сервере Prometheus")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(metricsPort)+"/metrics", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer awgm_node")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(string(body), `awg_peers_online{server="VPN",server_id="0"} 1`) {
|
||||||
|
t.Errorf("скрейп через агента: %d\n%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
// Второй скрейп сразу же — отказ: каждый опрашивает все серверы.
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 429 {
|
||||||
|
t.Errorf("частый скрейп: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-33
@@ -6,14 +6,15 @@ package ui
|
|||||||
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
|
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
|
||||||
//
|
//
|
||||||
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
|
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
|
||||||
// как чистая панель. Протокол и формат join-строки — docs/node-protocol.md.
|
// как чистая панель. Номер 0 тогда может занять нода — сервер, с которого панель переехала
|
||||||
|
// (`awg-control migrate`): так у его пиров, ключей API и устройств не меняется id сервера.
|
||||||
|
// Протокол и формат join-строки — docs/node-protocol.md.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -41,8 +42,10 @@ const (
|
|||||||
rpcTimeout = 15 * time.Second
|
rpcTimeout = 15 * time.Second
|
||||||
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
|
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
|
||||||
hubMaxFrame = 4 * 1024 * 1024
|
hubMaxFrame = 4 * 1024 * 1024
|
||||||
failWindow = 15 * time.Minute
|
// Метрики для Prometheus на ноде: каждый ответ опрашивает все серверы — не чаще этого.
|
||||||
failMaxHits = 20
|
metricsEvery = 5 * time.Second
|
||||||
|
failWindow = 15 * time.Minute
|
||||||
|
failMaxHits = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -108,6 +111,9 @@ type nodeConn struct {
|
|||||||
alive bool
|
alive bool
|
||||||
pending map[int64]*pending
|
pending map[int64]*pending
|
||||||
closed bool
|
closed bool
|
||||||
|
// Запрос метрик от ноды (Prometheus на ней): идёт ли сейчас и когда начат последний.
|
||||||
|
metricsBusy bool
|
||||||
|
metricsAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type deleteVeto struct {
|
type deleteVeto struct {
|
||||||
@@ -146,14 +152,15 @@ func newNodes(a *App) (*Nodes, error) {
|
|||||||
}
|
}
|
||||||
return filepath.Join(a.baseDir, name)
|
return filepath.Join(a.baseDir, name)
|
||||||
}
|
}
|
||||||
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
|
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Имя в нём случайное:
|
||||||
|
// одинаковое у всех серверов было бы сигнатурой для сканера (pin — по ключу, не по имени). Живёт столько же,
|
||||||
// сколько core: пересоздать = переподключить все ноды.
|
// сколько core: пересоздать = переподключить все ноды.
|
||||||
if n.port != 0 {
|
if n.port != 0 {
|
||||||
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
|
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
|
||||||
if err := func() error {
|
if err := func() error {
|
||||||
if !exists(keyF) && !exists(crtF) {
|
if !exists(keyF) && !exists(crtF) {
|
||||||
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
|
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 {
|
if err := cmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -201,6 +208,25 @@ func newNodes(a *App) (*Nodes, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// core_ack — адрес панели, который агент записал к себе (кадр core_ok): нода переживёт
|
||||||
|
// выключение сервера, через который подключена сейчас.
|
||||||
|
if !hasColumn(a.db, "nodes", "core_ack") {
|
||||||
|
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN core_ack TEXT"); err != nil {
|
||||||
|
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 — " +
|
||||||
|
"у этой панели свой VPN не включают")
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,15 +320,15 @@ func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
|
|||||||
|
|
||||||
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
|
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
|
||||||
func (n *Nodes) CtrlFor(id int) Ctrl {
|
func (n *Nodes) CtrlFor(id int) Ctrl {
|
||||||
if id == 0 {
|
if id == 0 && n.local {
|
||||||
if !n.local {
|
return n.a.ctrlLocal
|
||||||
|
}
|
||||||
|
if n.row(int64(id)) == nil {
|
||||||
|
if id == 0 {
|
||||||
return func(string, string, any) (*CtrlResp, error) {
|
return func(string, string, any) (*CtrlResp, error) {
|
||||||
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
|
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n.a.ctrlLocal
|
|
||||||
}
|
|
||||||
if n.row(int64(id)) == nil {
|
|
||||||
return func(string, string, any) (*CtrlResp, error) {
|
return func(string, string, any) (*CtrlResp, error) {
|
||||||
return &CtrlResp{404, errObj("Сервер не найден")}, nil
|
return &CtrlResp{404, errObj("Сервер не найден")}, nil
|
||||||
}
|
}
|
||||||
@@ -311,16 +337,16 @@ func (n *Nodes) CtrlFor(id int) Ctrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *Nodes) Exists(id int) bool {
|
func (n *Nodes) Exists(id int) bool {
|
||||||
if id == 0 {
|
if id == 0 && n.local {
|
||||||
return n.local
|
return true
|
||||||
}
|
}
|
||||||
return n.row(int64(id)) != nil
|
return n.row(int64(id)) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
|
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
|
||||||
func (n *Nodes) IsOnline(id int) bool {
|
func (n *Nodes) IsOnline(id int) bool {
|
||||||
if id == 0 {
|
if id == 0 && n.local {
|
||||||
return n.local
|
return true
|
||||||
}
|
}
|
||||||
n.mu.Lock()
|
n.mu.Lock()
|
||||||
defer n.mu.Unlock()
|
defer n.mu.Unlock()
|
||||||
@@ -385,7 +411,7 @@ func (n *Nodes) DefaultID() int {
|
|||||||
|
|
||||||
func (n *Nodes) refreshHealth(id int64) {
|
func (n *Nodes) refreshHealth(id int64) {
|
||||||
var r *CtrlResp
|
var r *CtrlResp
|
||||||
if id == 0 {
|
if id == 0 && n.local {
|
||||||
r, _ = n.a.ctrlLocal("GET", "/health", nil)
|
r, _ = n.a.ctrlLocal("GET", "/health", nil)
|
||||||
} else {
|
} else {
|
||||||
r = n.rpc(id, "GET", "/health", nil)
|
r = n.rpc(id, "GET", "/health", nil)
|
||||||
@@ -454,21 +480,64 @@ func (n *Nodes) dropConn(id int64, c *nodeConn) {
|
|||||||
n.mu.Unlock()
|
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) {
|
func closeWS(ws *websocket.Conn, code int, reason string) {
|
||||||
go ws.Close(websocket.StatusCode(code), reason)
|
go ws.Close(websocket.StatusCode(code), reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
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})
|
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.SetReadLimit(hubMaxFrame)
|
ws.SetReadLimit(hubMaxFrame)
|
||||||
if n.blocked(ip) {
|
|
||||||
ws.Close(4029, "rate_limited")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nb := make([]byte, 16)
|
nb := make([]byte, 16)
|
||||||
rand.Read(nb)
|
rand.Read(nb)
|
||||||
nonce := b64url.EncodeToString(nb)
|
nonce := b64url.EncodeToString(nb)
|
||||||
@@ -486,21 +555,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
row := n.row(int64(m.Get2("node_id").(float64)))
|
row := n.row(int64(m.Get2("node_id").(float64)))
|
||||||
if row == nil {
|
if row == nil || pre != nil && pre.ID != row.ID {
|
||||||
deny()
|
deny()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pubB64 := row.Pub.String
|
pubB64 := row.Pub.String
|
||||||
joining := !row.Pub.Valid
|
joining := !row.Pub.Valid
|
||||||
if joining {
|
if joining {
|
||||||
secret, sOK := field(m, "join", "secret").(string)
|
secret, _ := field(m, "join", "secret").(string)
|
||||||
pub, pOK := field(m, "join", "pub").(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 {
|
if !pOK || !joinOK(row, secret) {
|
||||||
deny()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h := sha256.Sum256(fromB64url(secret))
|
|
||||||
if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 {
|
|
||||||
deny()
|
deny()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -521,6 +585,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
version = v
|
version = v
|
||||||
}
|
}
|
||||||
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
|
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{}}
|
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
|
||||||
nodeID = row.ID
|
nodeID = row.ID
|
||||||
@@ -532,11 +606,18 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
n.dropConn(row.ID, old)
|
n.dropConn(row.ID, old)
|
||||||
closeWS(old.ws, 4000, "replaced")
|
closeWS(old.ws, 4000, "replaced")
|
||||||
}
|
}
|
||||||
ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"ready"}`))
|
ready := jsjson.Object{{Key: "t", Val: "ready"}}
|
||||||
|
if home := n.home(); home != "" {
|
||||||
|
ready = append(ready, jsjson.KV{Key: "core", Val: home})
|
||||||
|
}
|
||||||
|
ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(ready))
|
||||||
first := ""
|
first := ""
|
||||||
if joining {
|
if joining {
|
||||||
first = " (первое подключение)"
|
first = " (первое подключение)"
|
||||||
}
|
}
|
||||||
|
if pre == nil {
|
||||||
|
first += " — без подписи до апгрейда: старый агент, обновите ноду, чтобы хаб скрылся от сканеров"
|
||||||
|
}
|
||||||
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
|
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
|
||||||
go n.refreshHealth(row.ID)
|
go n.refreshHealth(row.ID)
|
||||||
go n.fireOnline(int(row.ID))
|
go n.fireOnline(int(row.ID))
|
||||||
@@ -561,6 +642,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
authenticate(m)
|
authenticate(m)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if m.Get2("t") == "core_ok" {
|
||||||
|
if home, ok := m.Get2("core").(string); ok && home == n.home() {
|
||||||
|
n.a.db.Exec("UPDATE nodes SET core_ack = ? WHERE id = ?", home, nodeID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Get2("t") == "metrics" && isInt(m.Get2("id")) {
|
||||||
|
go n.nodeMetrics(conn, m.Get2("id"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
|
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
|
||||||
rid := int64(m.Get2("id").(float64))
|
rid := int64(m.Get2("id").(float64))
|
||||||
conn.mu.Lock()
|
conn.mu.Lock()
|
||||||
@@ -584,6 +675,29 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodeMetrics — Prometheus на ноде скрейпит метрики панели через её агента: та же экспозиция,
|
||||||
|
// что /metrics ядра, со всеми серверами (docs/node-protocol.md, «Мониторинг на ноде»).
|
||||||
|
func (n *Nodes) nodeMetrics(c *nodeConn, id any) {
|
||||||
|
c.mu.Lock()
|
||||||
|
t := time.Now()
|
||||||
|
busy := c.metricsBusy || t.Sub(c.metricsAt) < metricsEvery
|
||||||
|
if !busy {
|
||||||
|
c.metricsBusy, c.metricsAt = true, t
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
res := jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: id}}
|
||||||
|
if busy {
|
||||||
|
res = append(res, jsjson.KV{Key: "status", Val: 429}, jsjson.KV{Key: "text", Val: "слишком часто\n"})
|
||||||
|
} else {
|
||||||
|
text := n.a.metrics.render()
|
||||||
|
c.mu.Lock()
|
||||||
|
c.metricsBusy = false
|
||||||
|
c.mu.Unlock()
|
||||||
|
res = append(res, jsjson.KV{Key: "status", Val: 200}, jsjson.KV{Key: "text", Val: text})
|
||||||
|
}
|
||||||
|
c.ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(res))
|
||||||
|
}
|
||||||
|
|
||||||
// Start поднимает хаб (если настроен) и периодические ping/health.
|
// Start поднимает хаб (если настроен) и периодические ping/health.
|
||||||
func (n *Nodes) Start() {
|
func (n *Nodes) Start() {
|
||||||
if n.local {
|
if n.local {
|
||||||
@@ -598,7 +712,7 @@ func (n *Nodes) Start() {
|
|||||||
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
|
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
|
||||||
ErrorLog: quietLog()}
|
ErrorLog: quietLog()}
|
||||||
go func() {
|
go func() {
|
||||||
ln, err := net.Listen("tcp", srv.Addr)
|
ln, err := listen(n.port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warnf("nodes: хаб не запущен: %v", err)
|
warnf("nodes: хаб не запущен: %v", err)
|
||||||
return
|
return
|
||||||
@@ -656,6 +770,16 @@ func (n *Nodes) Start() {
|
|||||||
|
|
||||||
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
|
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
|
||||||
|
|
||||||
|
// home — публичный адрес хаба, который он сообщает нодам в ready (агент звонит туда первым).
|
||||||
|
// Из SUB_HOST: у панели один публичный адрес; не задан — нодам хватает адреса из join.
|
||||||
|
func (n *Nodes) home() string {
|
||||||
|
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
|
||||||
|
if host == "" || !n.hubReady() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, strconv.Itoa(n.port))
|
||||||
|
}
|
||||||
|
|
||||||
func (n *Nodes) joinString(host string, id int64, secret []byte) string {
|
func (n *Nodes) joinString(host string, id int64, secret []byte) string {
|
||||||
b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port},
|
b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port},
|
||||||
{Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
|
{Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
|
||||||
@@ -709,7 +833,8 @@ func (n *Nodes) List() []serverInfo {
|
|||||||
}
|
}
|
||||||
for _, r := range n.all() {
|
for _, r := range n.all() {
|
||||||
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -723,16 +848,17 @@ type serverInfo struct {
|
|||||||
LastSeen any
|
LastSeen any
|
||||||
Version any
|
Version any
|
||||||
Health *health
|
Health *health
|
||||||
|
Outdated bool // старше MinNodeVersion: хаб её не подключает, пока не обновят
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s serverInfo) json() jsjson.Object {
|
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},
|
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) {
|
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
|
||||||
f := jsToNumber(c.Params["id"])
|
f := jsToNumber(c.Params["id"])
|
||||||
return int64(f), f == float64(int64(f)) && f >= 1
|
return int64(f), f == float64(int64(f)) && (f >= 1 || f == 0 && !n.local)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Nodes) router() *Router {
|
func (n *Nodes) router() *Router {
|
||||||
@@ -861,3 +987,15 @@ func (n *Nodes) router() *Router {
|
|||||||
})
|
})
|
||||||
return rt
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
|
||||||
|
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
|
||||||
|
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
|
||||||
|
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const proxyHeaderTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
|
||||||
|
func relayFrom() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
|
||||||
|
out[ip.String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
|
||||||
|
func listen(port int) (net.Listener, error) {
|
||||||
|
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if trusted := relayFrom(); len(trusted) > 0 {
|
||||||
|
return newProxyListener(ln, trusted), nil
|
||||||
|
}
|
||||||
|
return ln, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type accepted struct {
|
||||||
|
c net.Conn
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
|
||||||
|
// держит Accept для остальных.
|
||||||
|
type proxyListener struct {
|
||||||
|
net.Listener
|
||||||
|
trusted map[string]bool
|
||||||
|
ch chan accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
|
||||||
|
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
|
||||||
|
go l.loop()
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *proxyListener) loop() {
|
||||||
|
for {
|
||||||
|
c, err := l.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
l.ch <- accepted{nil, err}
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
|
||||||
|
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
|
||||||
|
l.ch <- accepted{c, nil}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if pc := readProxyHeader(c); pc != nil {
|
||||||
|
l.ch <- accepted{pc, nil}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *proxyListener) Accept() (net.Conn, error) {
|
||||||
|
a := <-l.ch
|
||||||
|
return a.c, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type proxyConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
remote net.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
|
||||||
|
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
|
||||||
|
|
||||||
|
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
|
||||||
|
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
|
||||||
|
func readProxyHeader(c net.Conn) net.Conn {
|
||||||
|
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
|
||||||
|
defer c.SetReadDeadline(time.Time{})
|
||||||
|
r := bufio.NewReaderSize(c, 256)
|
||||||
|
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
|
||||||
|
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
|
||||||
|
if err != nil && len(sig) == 0 {
|
||||||
|
c.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pc
|
||||||
|
}
|
||||||
|
// Строка v1 — не длиннее 107 байт вместе с CRLF.
|
||||||
|
line, err := r.ReadSlice('\n')
|
||||||
|
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
|
||||||
|
c.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
|
||||||
|
if addr != nil {
|
||||||
|
pc.remote = addr
|
||||||
|
}
|
||||||
|
return pc
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
|
||||||
|
func parseProxyV1(line string) (*net.TCPAddr, bool) {
|
||||||
|
f := strings.Split(line, " ")
|
||||||
|
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(f[2])
|
||||||
|
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(f[4])
|
||||||
|
if err != nil || port < 0 || port > 65535 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &net.TCPAddr{IP: ip, Port: port}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/jsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// После переноса старый сервер выключают, когда новый адрес знают все: хаб сообщает его
|
||||||
|
// нодам в ready и запоминает подтверждение (core_ok), клиенты получают его в endpoints.
|
||||||
|
// Вкладка «Перенос» показывает, сколько осталось.
|
||||||
|
func TestRetireReadiness(t *testing.T) {
|
||||||
|
t.Setenv("SUB_HOST", "198.51.100.20")
|
||||||
|
p := newMovePanel(t, "off", "40500")
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
for _, q := range []string{
|
||||||
|
"INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'Старый', 'x', 1)",
|
||||||
|
"INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', '" + b64url.EncodeToString(pub) + "', 1)",
|
||||||
|
"INSERT INTO nodes (id, name, created_at) VALUES (4, 'Ещё не подключена', 1)",
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (2, 'u2', 'Не открыта', x'02', 1)",
|
||||||
|
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (3, 'u3', 'Отозвана', x'03', 1, 1)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (5, 1, 'a', 'W5', 'A5', 1, 2000)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (6, 1, 'b', 'W6', 'A6', 1, 500)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (7, 1, 'c', 'W7', 'A7', 1)",
|
||||||
|
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (8, 3, 'd', 'W8', 'A8', 1, 2000)",
|
||||||
|
} {
|
||||||
|
if _, err := p.a.db.Exec(q); err != nil {
|
||||||
|
t.Fatal(q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
|
||||||
|
defer srv.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*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 {
|
||||||
|
_, raw, err := ws.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, _ := jsjson.Decode(raw)
|
||||||
|
o, _ := v.(jsjson.Object)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
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: "version", Val: MinNodeVersion}})
|
||||||
|
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
|
||||||
|
t.Fatalf("ready: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
retire := func() jsjson.Object {
|
||||||
|
_, o := p.call(t, p.a.migration.status, nil)
|
||||||
|
r, _ := o.Get2("retire").(jsjson.Object)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if retire() != nil {
|
||||||
|
t.Error("готовность показана панели, которая ничего не принимала")
|
||||||
|
}
|
||||||
|
t.Setenv("MOVED_IN_AT", "1000")
|
||||||
|
if n := string(jsjson.MustMarshal(retire().Get2("nodes"))); n != `[{"id":3,"name":"Frankfurt","online":true,"ok":false}]` {
|
||||||
|
t.Errorf("до core_ok: %s", n)
|
||||||
|
}
|
||||||
|
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "203.0.113.66:1"}}) // не наш адрес — мимо
|
||||||
|
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "198.51.100.20:40500"}})
|
||||||
|
var r jsjson.Object
|
||||||
|
for range 50 {
|
||||||
|
if r = retire(); strings.Contains(string(jsjson.MustMarshal(r.Get2("nodes"))), `"ok":true`) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
want := `{"since":1000,"home":"198.51.100.20:40500","old":{"exists":true,"online":false},` +
|
||||||
|
`"devices":{"total":3,"fresh":1},"unused_links":1,"nodes":[{"id":3,"name":"Frankfurt","online":true,"ok":true}]}`
|
||||||
|
if got := string(jsjson.MustMarshal(r)); got != want {
|
||||||
|
t.Errorf("готовность:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(p.env, []byte("UI_PASS=x\nMOVED_IN_AT=1000\n"), 0o600)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
p.a.migration.router().ServeHTTP(w, httptest.NewRequest("DELETE", "/retire", nil))
|
||||||
|
if raw, _ := os.ReadFile(p.env); strings.Contains(string(raw), "MOVED_IN_AT") || retire() != nil {
|
||||||
|
t.Errorf("отметка не убрана: %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-18
@@ -11,10 +11,10 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -91,6 +91,7 @@ func decodeLikeAxios(raw []byte) any {
|
|||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
port int
|
port int
|
||||||
|
prefixes []string // UI_PREFIX, UI_PREFIX_MOVED: секретные пути панели (prefix.go)
|
||||||
uiUser string
|
uiUser string
|
||||||
uiPass string
|
uiPass string
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
@@ -109,10 +110,11 @@ type App struct {
|
|||||||
revoked map[string]bool
|
revoked map[string]bool
|
||||||
loginAttempts map[string]*rateEntry
|
loginAttempts map[string]*rateEntry
|
||||||
|
|
||||||
nodes *Nodes
|
nodes *Nodes
|
||||||
sub *Sub
|
sub *Sub
|
||||||
metrics *Metrics
|
metrics *Metrics
|
||||||
monitor *Monitor
|
monitor *Monitor
|
||||||
|
migration *Migration
|
||||||
}
|
}
|
||||||
|
|
||||||
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
|
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
|
||||||
@@ -130,14 +132,15 @@ func New() (*App, error) {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
a := &App{
|
a := &App{
|
||||||
port: envNum("PORT"),
|
port: envNum("PORT"),
|
||||||
uiUser: envOr("UI_USER", "admin"),
|
prefixes: uiPrefixes(),
|
||||||
uiPass: os.Getenv("UI_PASS"),
|
uiUser: envOr("UI_USER", "admin"),
|
||||||
brand: pick("BRAND", "Forgetting"),
|
uiPass: os.Getenv("UI_PASS"),
|
||||||
channel: pick("CHANNEL", "Beta"),
|
brand: pick("BRAND", "Forgetting"),
|
||||||
version: pick("VERSION", ""),
|
channel: pick("CHANNEL", "Beta"),
|
||||||
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
|
version: pick("VERSION", ""),
|
||||||
baseDir: cwd,
|
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
|
||||||
|
baseDir: cwd,
|
||||||
// awg-ctrl на loopback: системный прокси сюда не нужен.
|
// awg-ctrl на loopback: системный прокси сюда не нужен.
|
||||||
httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||||
revoked: map[string]bool{},
|
revoked: map[string]bool{},
|
||||||
@@ -185,6 +188,7 @@ func New() (*App, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.monitor = newMonitor(a)
|
a.monitor = newMonitor(a)
|
||||||
|
a.migration = newMigration(a)
|
||||||
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
|
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
|
||||||
a.nodes.AfterDelete(a.apiKeysAfterDelete)
|
a.nodes.AfterDelete(a.apiKeysAfterDelete)
|
||||||
return a, nil
|
return a, nil
|
||||||
@@ -845,8 +849,9 @@ func (a *App) routes() *Router {
|
|||||||
|
|
||||||
app.Post("/login", a.login)
|
app.Post("/login", a.login)
|
||||||
app.Post("/logout", auth, a.logout)
|
app.Post("/logout", auth, a.logout)
|
||||||
// Единственный роут без авторизации: вордмарк нужен уже на экране логина.
|
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
|
||||||
app.Get("/ui/brand", func(c *Ctx) bool {
|
// Экран логина обходится без вордмарка.
|
||||||
|
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}})
|
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)
|
app.Get("/ui/apikeys", auth, a.apiKeysList)
|
||||||
@@ -874,8 +879,13 @@ func (a *App) routes() *Router {
|
|||||||
app.Use("/ui/metrics-token", auth)
|
app.Use("/ui/metrics-token", auth)
|
||||||
app.Mount("/ui/metrics-token", a.metrics.router())
|
app.Mount("/ui/metrics-token", a.metrics.router())
|
||||||
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
|
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
|
||||||
|
app.Get("/ui/connlog", auth, a.connlog)
|
||||||
app.Use("/ui/monitor", auth)
|
app.Use("/ui/monitor", auth)
|
||||||
app.Mount("/ui/monitor", a.monitor.router())
|
app.Mount("/ui/monitor", a.monitor.router())
|
||||||
|
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
|
||||||
|
app.Post("/migrate/v1/import", a.migration.importHandler)
|
||||||
|
app.Use("/ui/migrate", auth)
|
||||||
|
app.Mount("/ui/migrate", a.migration.router())
|
||||||
|
|
||||||
for _, p := range []string{"/api", "/health", "/awg"} {
|
for _, p := range []string{"/api", "/health", "/awg"} {
|
||||||
app.Use(p, auth, a.proxy)
|
app.Use(p, auth, a.proxy)
|
||||||
@@ -903,7 +913,9 @@ func (a *App) static(c *Ctx) bool {
|
|||||||
if st, err := os.Stat(f); err == nil && !hidden {
|
if st, err := os.Stat(f); err == nil && !hidden {
|
||||||
if st.IsDir() {
|
if st.IsDir() {
|
||||||
if !strings.HasSuffix(c.R.URL.Path, "/") {
|
if !strings.HasSuffix(c.R.URL.Path, "/") {
|
||||||
http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently)
|
// Относительный адрес: за прокси в подпапке абсолютный увёл бы из неё.
|
||||||
|
c.W.Header().Set("Location", path.Base(c.R.URL.Path)+"/")
|
||||||
|
c.W.WriteHeader(http.StatusMovedPermanently)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
f = filepath.Join(f, "index.html")
|
f = filepath.Join(f, "index.html")
|
||||||
@@ -937,11 +949,11 @@ func serveFile(w http.ResponseWriter, r *http.Request, f string) {
|
|||||||
|
|
||||||
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
|
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
|
ln, err := listen(a.port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
logf("ui: listening on :%d", a.port)
|
||||||
a.nodes.Start()
|
a.nodes.Start()
|
||||||
a.sub.Start()
|
a.sub.Start()
|
||||||
|
|||||||
+92
-45
@@ -29,13 +29,13 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -194,6 +194,8 @@ type Sub struct {
|
|||||||
|
|
||||||
registerLimit, failLimit *limiter
|
registerLimit, failLimit *limiter
|
||||||
|
|
||||||
|
frozen atomic.Bool // идёт перенос панели (migrate.go): /sub/v1 отвечает «недоступна»
|
||||||
|
|
||||||
auditMu sync.Mutex
|
auditMu sync.Mutex
|
||||||
lastAudit *auditReport
|
lastAudit *auditReport
|
||||||
auditSettings auditSettings
|
auditSettings auditSettings
|
||||||
@@ -286,6 +288,13 @@ func (s *Sub) initSchema() error {
|
|||||||
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE (master_id, device_id)
|
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 остался от времени одного сервера и
|
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
|
||||||
-- дальше не читается (кроме переноса ниже).
|
-- дальше не читается (кроме переноса ниже).
|
||||||
CREATE TABLE IF NOT EXISTS master_servers (
|
CREATE TABLE IF NOT EXISTS master_servers (
|
||||||
@@ -747,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)
|
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 уйдут каскадом
|
q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом
|
||||||
ids := []int{}
|
ids := []int{}
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
@@ -780,7 +792,8 @@ func (s *Sub) subHost(c *Ctx) string {
|
|||||||
if s.host != "" {
|
if s.host != "" {
|
||||||
return s.host
|
return s.host
|
||||||
}
|
}
|
||||||
if s.a.nodes.Exists(0) {
|
// Только свой сервер: у ноды №0 (переехавшей панели) endpoint — другая машина.
|
||||||
|
if s.a.nodes.local {
|
||||||
if p := s.storedProfile(0); p != nil {
|
if p := s.storedProfile(0); p != nil {
|
||||||
if ep, ok := p.Get2("endpoint").(string); ok {
|
if ep, ok := p.Get2("endpoint").(string); ok {
|
||||||
return hostOfEndpoint(ep)
|
return hostOfEndpoint(ep)
|
||||||
@@ -900,21 +913,23 @@ func reqPath(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога).
|
// 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"], ", "))
|
tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", "))
|
||||||
if _, has := c.R.Header["X-Sen-Ts"]; !has {
|
if _, has := c.R.Header["X-Sen-Ts"]; !has {
|
||||||
tsF = jsToNumber("x")
|
tsF = jsToNumber("x")
|
||||||
}
|
}
|
||||||
sig := c.R.Header.Get("X-Sen-Sig")
|
sig := c.R.Header.Get("X-Sen-Sig")
|
||||||
if tsF != float64(int64(tsF)) || sig == "" {
|
if tsF != float64(int64(tsF)) || sig == "" {
|
||||||
return "no_signature"
|
return "no_signature", false
|
||||||
}
|
}
|
||||||
ts := int64(tsF)
|
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) {
|
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()
|
s.sigMu.Lock()
|
||||||
defer s.sigMu.Unlock()
|
defer s.sigMu.Unlock()
|
||||||
@@ -925,10 +940,27 @@ func (s *Sub) signFailure(c *Ctx, authPub []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, ok := s.seenSigs[sig]; ok {
|
if _, ok := s.seenSigs[sig]; ok {
|
||||||
return "replay"
|
return "replay", true
|
||||||
}
|
}
|
||||||
s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second)
|
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 {
|
func (s *Sub) deviceAuth(c *Ctx) bool {
|
||||||
@@ -939,16 +971,31 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
|
|||||||
if valid {
|
if valid {
|
||||||
dev = s.deviceByID(s.a.db, int64(f))
|
dev = s.deviceByID(s.a.db, int64(f))
|
||||||
}
|
}
|
||||||
|
if !valid {
|
||||||
|
return stealth(c)
|
||||||
|
}
|
||||||
// Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и
|
// Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и
|
||||||
// не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT.
|
// не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за 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"))
|
return s.sendC(c, 410, subErr("revoked"))
|
||||||
}
|
}
|
||||||
why := "no_device"
|
why, signed := s.signFailure(c, fromB64url(dev.AuthPub))
|
||||||
if dev != nil {
|
if !signed {
|
||||||
why = s.signFailure(c, fromB64url(dev.AuthPub))
|
return stealth(c)
|
||||||
}
|
}
|
||||||
if why == "" {
|
if why == "" {
|
||||||
|
if s.frozenFor(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
c.device = dev
|
c.device = dev
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -956,11 +1003,7 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
|
|||||||
if !s.failLimit.allow(ip) {
|
if !s.failLimit.allow(ip) {
|
||||||
return s.sendC(c, 429, subErr("rate_limited"))
|
return s.sendC(c, 429, subErr("rate_limited"))
|
||||||
}
|
}
|
||||||
hdr := c.R.Header.Get("X-Sen-Device")
|
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)
|
||||||
if !hasHdr {
|
|
||||||
hdr = "-"
|
|
||||||
}
|
|
||||||
warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip)
|
|
||||||
return s.sendC(c, 401, subErr("unauthorized"))
|
return s.sendC(c, 401, subErr("unauthorized"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,10 +1026,6 @@ var errLimit = errors.New("limit")
|
|||||||
|
|
||||||
func (s *Sub) register(c *Ctx) error {
|
func (s *Sub) register(c *Ctx) error {
|
||||||
ip := c.remoteIP()
|
ip := c.remoteIP()
|
||||||
if !s.registerLimit.allow(ip) {
|
|
||||||
s.sendC(c, 429, subErr("rate_limited"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b := c.Obj()
|
b := c.Obj()
|
||||||
strRe := func(k string, re *regexp.Regexp) (string, bool) {
|
strRe := func(k string, re *regexp.Regexp) (string, bool) {
|
||||||
v, ok := b.Get2(k).(string)
|
v, ok := b.Get2(k).(string)
|
||||||
@@ -1007,7 +1046,23 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
if !ok5 {
|
if !ok5 {
|
||||||
version = ""
|
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"))
|
s.sendC(c, 400, subErr("bad_request"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1071,7 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
authPub := fromB64url(authPubS)
|
authPub := fromB64url(authPubS)
|
||||||
why := "bad_auth_pub"
|
why := "bad_auth_pub"
|
||||||
if len(authPub) == 32 {
|
if len(authPub) == 32 {
|
||||||
why = s.signFailure(c, authPub)
|
why, _ = s.signFailure(c, authPub)
|
||||||
}
|
}
|
||||||
if why != "" {
|
if why != "" {
|
||||||
warnf("sub: 401 register %s ip=%s", why, ip)
|
warnf("sub: 401 register %s ip=%s", why, ip)
|
||||||
@@ -1024,11 +1079,6 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
master := s.masterBySecret(fromB64url(secretB))
|
|
||||||
if master == nil {
|
|
||||||
s.sendC(c, 404, subErr("not_found"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var serverIDs []int
|
var serverIDs []int
|
||||||
for _, id := range s.serverIDsOf(s.a.db, master.ID) {
|
for _, id := range s.serverIDsOf(s.a.db, master.ID) {
|
||||||
if s.a.nodes.Exists(id) {
|
if s.a.nodes.Exists(id) {
|
||||||
@@ -1089,23 +1139,18 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
|
|
||||||
// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет).
|
// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет).
|
||||||
func (s *Sub) peek(c *Ctx) error {
|
func (s *Sub) peek(c *Ctx) error {
|
||||||
ip := c.remoteIP()
|
|
||||||
secretB, ok := c.Obj().Get2("sub").(string)
|
secretB, ok := c.Obj().Get2("sub").(string)
|
||||||
ok = ok && reSubSecret.MatchString(secretB)
|
ok = ok && reSubSecret.MatchString(secretB)
|
||||||
var master *masterRow
|
var master *masterRow
|
||||||
if ok {
|
if ok {
|
||||||
master = s.masterBySecret(fromB64url(secretB))
|
master = s.masterBySecret(fromB64url(secretB))
|
||||||
}
|
}
|
||||||
|
// Секрет не тот — ответа протокола нет (stealth): иначе сканер отличал бы подписку.
|
||||||
if master == nil {
|
if master == nil {
|
||||||
if !s.failLimit.allow(ip) {
|
stealth(c)
|
||||||
s.sendC(c, 429, subErr("rate_limited"))
|
return nil
|
||||||
return nil
|
}
|
||||||
}
|
if s.frozenFor(c) {
|
||||||
if ok {
|
|
||||||
s.sendC(c, 404, subErr("not_found"))
|
|
||||||
} else {
|
|
||||||
s.sendC(c, 400, subErr("bad_request"))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)},
|
s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)},
|
||||||
@@ -1196,10 +1241,12 @@ func (s *Sub) publicRouter() *Router {
|
|||||||
rt := newRouter()
|
rt := newRouter()
|
||||||
rt.bodyLimit = 4 * 1024
|
rt.bodyLimit = 4 * 1024
|
||||||
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
||||||
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) {
|
// Всё, что не доказало знание секрета ссылки или ключа устройства, получает ответ пустого
|
||||||
s.send(w, r, 400, subErr("bad_request"))
|
// Go-сервера (stealth): битое тело, чужой путь, чужой метод. Подписанные ответы — только
|
||||||
}
|
// своим. Перенос панели (frozen) — тоже только им: 502 unavailable клиент понимает как
|
||||||
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
|
// «подписка недоступна», туннель не трогает и повторит позже (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/register", s.wrapSub(s.register))
|
||||||
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
|
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
|
||||||
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
|
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
|
||||||
@@ -1248,7 +1295,7 @@ func (s *Sub) Start() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
srv := &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: s.publicRouter(), ReadHeaderTimeout: 15 * time.Second, ErrorLog: quietLog()}
|
srv := &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: s.publicRouter(), ReadHeaderTimeout: 15 * time.Second, ErrorLog: quietLog()}
|
||||||
ln, err := net.Listen("tcp", srv.Addr)
|
ln, err := listen(s.port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warnf("sub: листенер не запущен: %v", err)
|
warnf("sub: листенер не запущен: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+227
-32
@@ -210,6 +210,12 @@ func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
|
|||||||
list = append(list, float64(id))
|
list = append(list, float64(id))
|
||||||
}
|
}
|
||||||
src = list
|
src = list
|
||||||
|
} else if def := s.defaultServers(); def != nil {
|
||||||
|
list := []any{}
|
||||||
|
for _, id := range def {
|
||||||
|
list = append(list, float64(id))
|
||||||
|
}
|
||||||
|
src = list
|
||||||
} else {
|
} else {
|
||||||
src = []any{float64(s.a.nodes.DefaultID())}
|
src = []any{float64(s.a.nodes.DefaultID())}
|
||||||
}
|
}
|
||||||
@@ -281,37 +287,7 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
|
|||||||
removed = append(removed, x)
|
removed = append(removed, x)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
devices := s.devicesOf(s.a.db, m.ID)
|
if err := s.a.tx(func(tx *sql.Tx) error { return s.moveServersTx(tx, m.ID, added, removed) }); err != nil {
|
||||||
if err := s.a.tx(func(tx *sql.Tx) error {
|
|
||||||
for _, sid := range removed {
|
|
||||||
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, d := range devices {
|
|
||||||
for _, l := range s.linksOf(tx, d.ID) {
|
|
||||||
if l.ServerID == sid && l.PeerPub.Valid {
|
|
||||||
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, sid := range added {
|
|
||||||
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, d := range devices {
|
|
||||||
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
affected = append(append(affected, added...), removed...)
|
affected = append(append(affected, added...), removed...)
|
||||||
@@ -339,6 +315,215 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
|
|||||||
{Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil
|
{Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moveServersTx — ключ получает серверы added и теряет removed: пиры его устройств на снятых
|
||||||
|
// серверах — в надгробия (их снимет reconcile), на добавленных — заявки без пира (заведёт он же).
|
||||||
|
func (s *Sub) moveServersTx(tx *sql.Tx, masterID int64, added, removed []int) error {
|
||||||
|
devices := s.devicesOf(tx, masterID)
|
||||||
|
for _, sid := range removed {
|
||||||
|
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", masterID, sid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, d := range devices {
|
||||||
|
for _, l := range s.linksOf(tx, d.ID) {
|
||||||
|
if l.ServerID == sid && l.PeerPub.Valid {
|
||||||
|
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sid := range added {
|
||||||
|
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", masterID, sid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, d := range devices {
|
||||||
|
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Общая настройка серверов ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// defaultServers — серверы новых ключей панели (без явного набора), из настройки «Серверы для
|
||||||
|
// всех»; nil — не задано или ни одного из них уже нет.
|
||||||
|
func (s *Sub) defaultServers() []int {
|
||||||
|
var out []int
|
||||||
|
for _, v := range arr(s.metaGet("default_servers")) {
|
||||||
|
if isInt(v) && s.a.nodes.Exists(int(v.(float64))) {
|
||||||
|
out = append(out, int(v.(float64)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulkServers — один набор правок для всех живых ключей: add — у всех, remove — ни у кого,
|
||||||
|
// остальные серверы каждого ключа не трогаются. dry — только посчитать. hidden — ключи API,
|
||||||
|
// которые потеряют доступ к части ключей: ключ виден API-ключу, только пока все его серверы в
|
||||||
|
// области (visible), и добавление сервера вне области его прячет.
|
||||||
|
func (s *Sub) bulkServers(body any) (jsjson.Object, error) {
|
||||||
|
b, _ := body.(jsjson.Object)
|
||||||
|
ids := func(k string) ([]int, error) {
|
||||||
|
v, has := b.Get(k)
|
||||||
|
if !has || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if l, ok := v.([]any); ok && len(l) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out, ok := s.a.serverList(v, s.a.nodes.Exists)
|
||||||
|
if !ok {
|
||||||
|
return nil, fail(400, "Такого сервера нет")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
add, err := ids("add")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remove, err := ids("remove")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range add {
|
||||||
|
if containsInt(remove, x) {
|
||||||
|
return nil, fail(400, "Сервер не может быть и у всех, и ни у кого")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaults, hasDefaults := b.Get("defaults")
|
||||||
|
var defIDs []int
|
||||||
|
if hasDefaults && defaults != nil {
|
||||||
|
if defIDs, err = s.parseServers(defaults, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type change struct {
|
||||||
|
id int64
|
||||||
|
added, removed []int
|
||||||
|
before, after []int
|
||||||
|
}
|
||||||
|
var changes []change
|
||||||
|
var masters []int64
|
||||||
|
rows, err := s.a.db.Query("SELECT id FROM master_keys WHERE revoked_at IS NULL ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
masters = append(masters, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
empty := 0
|
||||||
|
for _, id := range masters {
|
||||||
|
cur := s.serverIDsOf(s.a.db, id)
|
||||||
|
c := change{id: id, before: cur}
|
||||||
|
for _, x := range cur {
|
||||||
|
if containsInt(remove, x) {
|
||||||
|
c.removed = append(c.removed, x)
|
||||||
|
} else {
|
||||||
|
c.after = append(c.after, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, x := range add {
|
||||||
|
if !containsInt(cur, x) {
|
||||||
|
c.added = append(c.added, x)
|
||||||
|
c.after = append(c.after, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.after) == 0 {
|
||||||
|
empty++
|
||||||
|
}
|
||||||
|
if len(c.added)+len(c.removed) > 0 {
|
||||||
|
changes = append(changes, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if empty > 0 {
|
||||||
|
return nil, fail(409, fmt.Sprintf("У %d ключей не останется ни одного сервера — оставьте им хотя бы один", empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Области ключей API: id → серверы.
|
||||||
|
type apiKey struct {
|
||||||
|
label string
|
||||||
|
scope []int
|
||||||
|
}
|
||||||
|
keys := map[int64]*apiKey{}
|
||||||
|
var order []int64
|
||||||
|
if r, err := s.a.db.Query("SELECT k.id, k.label, ks.server_id FROM api_keys k LEFT JOIN api_key_servers ks ON ks.key_id = k.id ORDER BY k.id"); err == nil {
|
||||||
|
for r.Next() {
|
||||||
|
var id int64
|
||||||
|
var label string
|
||||||
|
var sid sql.NullInt64
|
||||||
|
if r.Scan(&id, &label, &sid) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keys[id] == nil {
|
||||||
|
keys[id] = &apiKey{label: label}
|
||||||
|
order = append(order, id)
|
||||||
|
}
|
||||||
|
if sid.Valid {
|
||||||
|
keys[id].scope = append(keys[id].scope, int(sid.Int64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Close()
|
||||||
|
}
|
||||||
|
within := func(list, scope []int) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if !containsInt(scope, x) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hidden := []any{}
|
||||||
|
for _, id := range order {
|
||||||
|
k, n := keys[id], 0
|
||||||
|
for _, c := range changes {
|
||||||
|
if within(c.before, k.scope) && !within(c.after, k.scope) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
hidden = append(hidden, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: k.label}, {Key: "keys", Val: n}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := jsjson.Object{{Key: "changed", Val: len(changes)}, {Key: "total", Val: len(masters)}, {Key: "hidden", Val: hidden}}
|
||||||
|
if b.Get2("dry_run") == true {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var affected []int
|
||||||
|
if err := s.a.tx(func(tx *sql.Tx) error {
|
||||||
|
for _, c := range changes {
|
||||||
|
if err := s.moveServersTx(tx, c.id, c.added, c.removed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected = append(append(affected, c.added...), c.removed...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hasDefaults {
|
||||||
|
if defIDs == nil {
|
||||||
|
s.a.db.Exec("DELETE FROM sub_meta WHERE key = 'default_servers'")
|
||||||
|
} else {
|
||||||
|
s.metaSet("default_servers", intsJSON(defIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logf("masterkeys: серверы для всех — добавлены %v, сняты %v, ключей изменено: %d", add, remove, len(changes))
|
||||||
|
s.reconcileAll(affected)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
|
// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
|
||||||
func (s *Sub) rotateMaster(m *masterRow) {
|
func (s *Sub) rotateMaster(m *masterRow) {
|
||||||
secret := make([]byte, 16)
|
secret := make([]byte, 16)
|
||||||
@@ -571,8 +756,18 @@ func (s *Sub) masterKeysRouter() *Router {
|
|||||||
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
|
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
|
||||||
|
|
||||||
rt.Get("/", func(c *Ctx) bool {
|
rt.Get("/", func(c *Ctx) bool {
|
||||||
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)}})
|
var defaults any
|
||||||
|
if d := s.defaultServers(); d != nil {
|
||||||
|
defaults = intsJSON(d)
|
||||||
|
}
|
||||||
|
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)},
|
||||||
|
{Key: "defaults", Val: defaults}})
|
||||||
})
|
})
|
||||||
|
// «Серверы для всех»: добавить/снять серверы у всех ключей разом и задать набор для новых.
|
||||||
|
rt.Post("/servers", aw(func(c *Ctx) (int, any, error) {
|
||||||
|
out, err := s.bulkServers(c.Body)
|
||||||
|
return 200, out, err
|
||||||
|
}))
|
||||||
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
|
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
|
||||||
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
|
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
|
||||||
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {
|
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
// Панель за reverse-proxy (nginx и т. п., в корне домена или в подпапке): соединение приходит от
|
||||||
|
// прокси, а адрес клиента — в X-Forwarded-For / X-Real-IP. Иначе все клиенты были бы одним IP и
|
||||||
|
// делили бы лимиты по адресу (неудачные входы, регистрации). Заголовкам верим только от прокси:
|
||||||
|
// loopback (прокси на этой же машине) всегда, остальные адреса и сети — из TRUST_PROXY. От всех
|
||||||
|
// прочих заголовки игнорируются — подделать свой адрес клиенту нечем.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trustedProxies — TRUST_PROXY: IP и сети (CIDR) через запятую; loopback доверен без него.
|
||||||
|
var trustedProxies = sync.OnceValue(func() []*net.IPNet { return parseTrustProxy(os.Getenv("TRUST_PROXY")) })
|
||||||
|
|
||||||
|
func parseTrustProxy(v string) []*net.IPNet {
|
||||||
|
var out []*net.IPNet
|
||||||
|
for _, s := range strings.Split(v, ",") {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
if ip := net.ParseIP(s); ip != nil {
|
||||||
|
bits := 128
|
||||||
|
if ip.To4() != nil {
|
||||||
|
bits = 32
|
||||||
|
}
|
||||||
|
s = ip.String() + "/" + strconv.Itoa(bits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, n, err := net.ParseCIDR(s); err == nil {
|
||||||
|
out = append(out, n)
|
||||||
|
} else {
|
||||||
|
warnf("TRUST_PROXY: «%s» — не IP и не сеть, пропущено", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrustedProxy(ip net.IP, nets []*net.IPNet) bool {
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, n := range nets {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP — адрес клиента: от доверенного прокси — из X-Forwarded-For (справа налево, первый
|
||||||
|
// адрес не из доверенных: левые части клиент мог дописать сам) или X-Real-IP; иначе — сам peer.
|
||||||
|
func clientIP(peer string, h interface{ Values(string) []string }, nets []*net.IPNet) string {
|
||||||
|
ip := net.ParseIP(peer)
|
||||||
|
if ip == nil || !isTrustedProxy(ip, nets) {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
var hops []string
|
||||||
|
for _, v := range h.Values("X-Forwarded-For") {
|
||||||
|
hops = append(hops, strings.Split(v, ",")...)
|
||||||
|
}
|
||||||
|
found := ""
|
||||||
|
for i := len(hops) - 1; i >= 0; i-- {
|
||||||
|
hop := net.ParseIP(strings.TrimSpace(hops[i]))
|
||||||
|
if hop == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
found = hop.String()
|
||||||
|
if !isTrustedProxy(hop, nets) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
for _, v := range h.Values("X-Real-IP") {
|
||||||
|
if r := net.ParseIP(strings.TrimSpace(v)); r != nil {
|
||||||
|
return r.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peer
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// За reverse-proxy адрес клиента берётся из его заголовков, но только когда соединение пришло от
|
||||||
|
// самого прокси (loopback или TRUST_PROXY): напрямую подставить себе чужой IP нельзя.
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
nets := parseTrustProxy("10.0.0.0/8, 192.0.2.7, мусор")
|
||||||
|
for _, c := range []struct {
|
||||||
|
name, peer string
|
||||||
|
xff []string
|
||||||
|
real, want string
|
||||||
|
}{
|
||||||
|
{"напрямую, заголовки игнорируются", "203.0.113.5", []string{"1.2.3.4"}, "1.2.3.4", "203.0.113.5"},
|
||||||
|
{"nginx на этой машине", "127.0.0.1", []string{"203.0.113.5"}, "", "203.0.113.5"},
|
||||||
|
{"IPv6 loopback", "::1", []string{"2001:db8::5"}, "", "2001:db8::5"},
|
||||||
|
{"клиент дописал себе адрес слева", "127.0.0.1", []string{"6.6.6.6, 203.0.113.5"}, "", "203.0.113.5"},
|
||||||
|
{"цепочка доверенных прокси", "10.1.1.1", []string{"203.0.113.5, 10.2.2.2", "192.0.2.7"}, "", "203.0.113.5"},
|
||||||
|
{"только X-Real-IP", "127.0.0.1", nil, "203.0.113.9", "203.0.113.9"},
|
||||||
|
{"прокси без заголовков", "127.0.0.1", nil, "", "127.0.0.1"},
|
||||||
|
{"мусор в X-Forwarded-For", "127.0.0.1", []string{"unknown"}, "203.0.113.9", "203.0.113.9"},
|
||||||
|
{"чужой прокси не доверен", "198.51.100.1", []string{"203.0.113.5"}, "", "198.51.100.1"},
|
||||||
|
} {
|
||||||
|
h := http.Header{}
|
||||||
|
for _, v := range c.xff {
|
||||||
|
h.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
if c.real != "" {
|
||||||
|
h.Set("X-Real-IP", c.real)
|
||||||
|
}
|
||||||
|
if got := clientIP(c.peer, h, nets); got != c.want {
|
||||||
|
t.Errorf("%s: %s, ожидали %s", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(nets) != 2 {
|
||||||
|
t.Errorf("TRUST_PROXY разобран в %d сетей", len(nets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Каталог без слэша — относительный редирект: за прокси в подпапке абсолютный увёл бы из неё.
|
||||||
|
func TestStaticRelativeRedirect(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir, "dist", "assets"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "dist", "assets", "index.html"), []byte("x"), 0o644)
|
||||||
|
a := &App{baseDir: dir}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.static(&Ctx{W: w, R: httptest.NewRequest("GET", "/assets", nil)})
|
||||||
|
if w.Code != 301 || w.Header().Get("Location") != "assets/" {
|
||||||
|
t.Errorf("%d %q", w.Code, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -212,12 +212,13 @@ func (c *Ctx) Obj() jsjson.Object {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// remoteIP — req.socket.remoteAddress.
|
// remoteIP — req.socket.remoteAddress.
|
||||||
|
// remoteIP — адрес клиента; за доверенным reverse-proxy — из его заголовков (trustproxy.go).
|
||||||
func (c *Ctx) remoteIP() string {
|
func (c *Ctx) remoteIP() string {
|
||||||
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
|
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
return host
|
return clientIP(host, c.R.Header, trustedProxies())
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.
|
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user