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 | ||
|
|
0687650d26 | ||
|
|
3511b8c6c3 | ||
|
|
28ccc17bde | ||
|
|
d6fc2049c3 | ||
|
|
ea91265674 | ||
|
|
de8a32ac41 | ||
|
|
a84845cecb | ||
|
|
c73da602d6 | ||
|
|
cf6af5ce0a | ||
|
|
105f184a93 | ||
|
|
2388a42275 | ||
|
|
e70c5468f5 | ||
|
|
bac230f144 | ||
|
|
5aa0c3a2f5 | ||
|
|
70bb587216 | ||
|
|
c2e9636f31 | ||
|
|
5687ba0d3d | ||
|
|
016d3de62d | ||
|
|
59e076c1de | ||
|
|
53d5a08bbd | ||
|
|
7fbff82e76 | ||
|
|
565b2759ed | ||
|
|
f0f2f9848a | ||
|
|
5c3bf21918 | ||
|
|
eb0ec2878c | ||
|
|
a85f5d87b2 | ||
|
|
c5e1dc9b32 | ||
|
|
3eede2f015 | ||
|
|
2054122110 | ||
|
|
129f29d6be | ||
|
|
2f2f0e268a | ||
|
|
2e773deba2 | ||
|
|
aa31b7b8a2 | ||
|
|
789538756a | ||
|
|
1278ad527d | ||
|
|
6e010a0141 | ||
|
|
923bd87fe9 | ||
|
|
d5e00ddaf8 | ||
|
|
2b46d04af9 | ||
|
|
a358504e2a | ||
|
|
66ae59e1d0 | ||
|
|
a11cfc88de | ||
|
|
44c032b376 | ||
|
|
068cab4d24 | ||
|
|
21c7409017 | ||
|
|
ca1c8e8c68 | ||
|
|
602c0416ba | ||
|
|
18972ed58f | ||
|
|
38e76da84f | ||
|
|
0dbb13f120 | ||
|
|
b097ccd5fb | ||
|
|
c0e19f284d |
@@ -0,0 +1,14 @@
|
||||
# Имя панели и версия. Единственное место, где они заданы: отсюда их читают CLI
|
||||
# (баннер меню) и awg-ui (вордмарк в панели, GET /ui/brand). Файл едет в архив
|
||||
# релиза и лежит в /opt/awg-control/.env на сервере.
|
||||
#
|
||||
# Переменные окружения перекрывают эти значения (BRAND=… в cli.env пишет
|
||||
# install.sh, если оператор задал своё имя при установке).
|
||||
#
|
||||
# ⚠️ VERSION и CHANNEL продублированы в install.sh: установщик печатает баннер и
|
||||
# собирает URL релиза ДО того, как архив с этим файлом скачан. При бампе версии
|
||||
# правь оба места (и package.json сервисов).
|
||||
|
||||
BRAND=Forgetting
|
||||
CHANNEL=Beta
|
||||
VERSION=0.5.0
|
||||
@@ -0,0 +1,60 @@
|
||||
# Проверка на каждый пуш и PR (Gitea Actions). Ловит то, из-за чего main однажды уехал
|
||||
# нерабочим: файл, на который есть import, не попал в коммит (tsc по awg-ui
|
||||
# видит это как «Cannot find module»), плюс битый синтаксис установщика.
|
||||
# Релиз собирается отдельно (release.yml, только на теги v*).
|
||||
#
|
||||
# Требует включённых Actions в настройках репозитория и раннера с меткой
|
||||
# ubuntu-latest — иначе поправь runs-on под свою метку.
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Installer syntax
|
||||
run: bash -n install.sh
|
||||
|
||||
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
|
||||
# jsjson.KV{k, v} без имён полей написан так намеренно.
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Go build, vet, test
|
||||
run: |
|
||||
go build ./...
|
||||
go vet -composites=false ./...
|
||||
go test -race ./...
|
||||
|
||||
- name: awg-ctrl types
|
||||
working-directory: awg-ctrl
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
# tsconfig awg-ui включает server.ts — именно этот шаг и поймал бы
|
||||
# пропавший ./ma7.
|
||||
- name: awg-ui types and build
|
||||
working-directory: awg-ui
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
npm run build
|
||||
|
||||
- name: cli types
|
||||
working-directory: cli
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
@@ -0,0 +1,91 @@
|
||||
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает те
|
||||
# же архивы, но заливает его через API Gitea — gh CLI в раннере нет.
|
||||
#
|
||||
# Gitea читает workflow из .gitea/workflows; когда этот каталог есть, файлы из
|
||||
# .github/workflows она не запускает, так что GitHub-версия остаётся только для
|
||||
# GitHub.
|
||||
#
|
||||
# Что нужно на стороне сервера, иначе workflow просто не запустится:
|
||||
# 1. Репозиторий НЕ должен быть pull-зеркалом — в зеркале Actions не
|
||||
# запускаются, а релизы через API не создаются (сейчас
|
||||
# maeneko/forgetting — зеркало GitHub, has_actions: false).
|
||||
# 2. В настройках репозитория включены Actions.
|
||||
# 3. Зарегистрирован act_runner с меткой ubuntu-latest (иначе поправь runs-on
|
||||
# под свою метку — Gitea принимает только `runs-on: метка`).
|
||||
# 4. Токен: по умолчанию берётся встроенный secrets.GITEA_TOKEN. Если ему не
|
||||
# хватает прав, заведи секрет RELEASE_TOKEN с PAT (имя не может начинаться
|
||||
# с GITEA_/GITHUB_ — эти префиксы зарезервированы).
|
||||
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: awg-ui
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
|
||||
- name: Package archives
|
||||
run: sh tools/release/package.sh "${GITHUB_REF_NAME#v}"
|
||||
|
||||
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
|
||||
# зависит от версии движка выражений в Gitea.
|
||||
- name: Publish release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TOKEN="${RELEASE_TOKEN:-$GITEA_TOKEN}"
|
||||
[ -n "$TOKEN" ] || { echo "нет токена: ни RELEASE_TOKEN, ни GITEA_TOKEN"; exit 1; }
|
||||
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
# id релиза — первое поле в JSON Gitea, поэтому берём ПЕРВОЕ вхождение
|
||||
# "id" (дальше идут id автора и ассетов; жадный sed схватил бы их).
|
||||
# jq в раннере может не быть, поэтому grep. Если релиз на этот тег уже
|
||||
# есть (повторный запуск), создание вернёт ошибку — берём его по тегу.
|
||||
# `|| true` обязателен: без совпадения grep вернёт 1, а с pipefail
|
||||
# это уронило бы шаг там, где пустой ответ — штатная ветка.
|
||||
release_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -n1 | grep -oE '[0-9]+$' || true; }
|
||||
|
||||
id=$(curl -sS -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":true}" \
|
||||
| release_id)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "релиз ${GITHUB_REF_NAME} уже существует — дополняем его"
|
||||
id=$(curl -sS -H "Authorization: token $TOKEN" \
|
||||
"$API/releases/tags/${GITHUB_REF_NAME}" | release_id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "не удалось получить id релиза"; exit 1; }
|
||||
|
||||
for ARCHIVE in awgcontrol-"${VERSION}"-linux-*.tar.gz; do
|
||||
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
|
||||
done
|
||||
|
||||
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Проверка на каждый пуш и PR. Ловит то, из-за чего main однажды уехал
|
||||
# нерабочим: файл, на который есть import, не попал в коммит (tsc по awg-ui
|
||||
# видит это как «Cannot find module»), плюс битый синтаксис установщика.
|
||||
# Релиз собирается отдельно (release.yml, только на теги v*).
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Installer syntax
|
||||
run: bash -n install.sh
|
||||
|
||||
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
|
||||
# jsjson.KV{k, v} без имён полей написан так намеренно.
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Go build, vet, test
|
||||
run: |
|
||||
go build ./...
|
||||
go vet -composites=false ./...
|
||||
go test -race ./...
|
||||
|
||||
- name: awg-ctrl types
|
||||
working-directory: awg-ctrl
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
# tsconfig awg-ui включает server.ts — именно этот шаг и поймал бы
|
||||
# пропавший ./ma7.
|
||||
- name: awg-ui types and build
|
||||
working-directory: awg-ui
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
npm run build
|
||||
|
||||
- name: cli types
|
||||
working-directory: cli
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# Ручной запуск: тег создаётся при публикации на коммите, с которого запущен workflow.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Тег релиза, например v0.4.0'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -11,6 +17,8 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -24,17 +32,18 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Package archive
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
mkdir "awgcontrol-${VERSION}"
|
||||
cp -r awg-ctrl awg-ui cli "awgcontrol-${VERSION}/"
|
||||
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
|
||||
- name: Package archives
|
||||
run: sh tools/release/package.sh "${TAG#v}"
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
|
||||
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
|
||||
VERSION="${TAG#v}"
|
||||
gh release create "${TAG}" awgcontrol-"${VERSION}"-linux-*.tar.gz \
|
||||
--target "${GITHUB_SHA}" --title "${TAG}" --generate-notes --prerelease
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
cli/cli.env
|
||||
awg-ui/panel.id
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -19,4 +20,7 @@ yarn-error.log*
|
||||
|
||||
# Все markdown, кроме README.md
|
||||
*.md
|
||||
!README.md
|
||||
!README.md
|
||||
# Go: бинарники и прогоны дифференциального стенда
|
||||
tools/diffbench/bin/
|
||||
tools/diffbench/out/
|
||||
|
||||
@@ -1,25 +1,67 @@
|
||||
# Forgetting
|
||||
Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module)
|
||||
Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module)
|
||||
> [!WARNING]
|
||||
> Данный проект предназначен только для использования в законных целях.
|
||||
|
||||
- **VPN-пользователи** с ключами `vpn://` для AmneziaVPN и **мастер-ключи** `sen://` для SenAWG:
|
||||
одна ссылка на несколько устройств, каждое регистрируется само.
|
||||
- **Несколько серверов** под одной панелью: ноды подключаются к ней сами, панель можно
|
||||
вынести на отдельный сервер без VPN.
|
||||
- **Журнал подключений**: кто, когда и сколько был в VPN, кто давно не подключался.
|
||||
- **Мониторинг**: встроенный Prometheus и графики в панели, метрики для своего Prometheus.
|
||||
- **Защита от обнаружения**: параметры обфускации, порты и пути у каждого сервера свои, а
|
||||
открытые порты отвечают сканеру как пустой веб-сервер.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Архитектура](#архитектура)
|
||||
- [Требования](#требования)
|
||||
- [Установка](#установка) · [Параметры установщика](#параметры-установщика) · [Обновление и удаление](#обновление-и-удаление)
|
||||
- [Несколько серверов](#несколько-серверов)
|
||||
- [Защита от обнаружения](#защита-от-обнаружения)
|
||||
- [Журнал подключений](#журнал-подключений)
|
||||
- [Мониторинг](#мониторинг)
|
||||
- [Панель за nginx](#панель-за-nginx)
|
||||
- [API](#api)
|
||||
- [Управление](#управление) · [Настройки `cli.env`](#настройки-clienv)
|
||||
|
||||
## Архитектура
|
||||
|
||||
Три компонента, устанавливаются в `/opt/awg-control/`:
|
||||
Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли;
|
||||
команда `awg-ctrl` — ссылка на него:
|
||||
|
||||
| Компонент | Роль |
|
||||
|---|---|
|
||||
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
|
||||
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), внешний API (`/api/v1`), прокси к awg-ctrl. Единственный сервис, доступный по сети. |
|
||||
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
|
||||
| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу (`users.db`) и журнал подключений, генерирует `vpn://` ключи. Слушает только loopback. |
|
||||
| **awg-ui** (`awg-control ui`) | Веб-панель (React, `awg-ui/dist`), вход по JWT, прокси к awg-ctrl, публичный API `/api/v1`, подписка `sen://`, хаб нод и `/metrics`. База — `ui.db`. |
|
||||
| **awg-agent** (`awg-control agent`) | Только на ноде: исходящее WSS-соединение к панели, исполняет её запросы на локальном awg-ctrl. |
|
||||
| **cli** (`awg-ctrl …`) | Менеджер процессов, логин и пароль панели, привязка нод, перенос панели, мониторинг, 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)
|
||||
- **ОС**: Ubuntu 24.04+ (тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка
|
||||
экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и
|
||||
переопределяется через `AMNEZIA_PPA_SUITE`).
|
||||
- **Права**: root.
|
||||
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
|
||||
- **Виртуализация**: KVM или железо (не OpenVZ/LXC/Docker/WSL — нужен загружаемый
|
||||
kernel-модуль AmneziaWG).
|
||||
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
|
||||
- **Node.js 20.x** (ставится установщиком автоматически).
|
||||
- **Архитектура**: x86_64 или aarch64.
|
||||
|
||||
Совместимость установщик проверяет сам перед любыми изменениями.
|
||||
|
||||
@@ -30,115 +72,436 @@
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
|
||||
```
|
||||
|
||||
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
|
||||
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор с текстом скрипта.
|
||||
|
||||
```bash
|
||||
sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)"
|
||||
```
|
||||
|
||||
Установщик спросит роль сервера:
|
||||
|
||||
| Роль | Что ставится |
|
||||
|---|---|
|
||||
| **Панель + 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/v1` — стабильный контракт
|
||||
для сторонних программ, авторизация по API-ключу. **Роуты панели** — всё, что умеет
|
||||
веб-интерфейс, авторизация по JWT.
|
||||
|
||||
В примерах `HOST:PORT` — адрес панели **вместе с секретным путём**:
|
||||
`http://203.0.113.9:39708/k3f9xQ2mTq8vLw0a`. Без пути снаружи любой запрос получает `404`.
|
||||
|
||||
> [!WARNING]
|
||||
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный контракт,
|
||||
> в отличие от `/api/v1`. По голому HTTP они идут открытым текстом: ставьте панель за TLS.
|
||||
|
||||
### Публичный API (`/api/v1`)
|
||||
|
||||
Ключ создаётся в «Настройках», на вкладке «API-ключи», и уходит в заголовке `X-Api-Key`.
|
||||
Открытое значение показывается один раз при создании — на сервере лежит только
|
||||
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
|
||||
`psk_key` и `pub_key` не покидают сервер.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://HOST:PORT/api/v1/users \
|
||||
-H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
|
||||
-d '{"name":"alice"}' | jq -r .vpn_key
|
||||
```
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `POST /api/v1/users` | Создаёт пира. Тело `{ "name": "alice" }`. `201` с `{ name, ip, gen, vpn_key }`; `400` — имя не прошло валидацию, `409` — уже есть |
|
||||
| `GET /api/v1/users` | Список со счётчиками: `{ users: [{ name, ip, key_gen, online, lastHandshake, rx, tx }] }` |
|
||||
| `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден |
|
||||
| `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` |
|
||||
|
||||
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или `"3.1"`). Операции
|
||||
только CRD: у пира нет изменяемых полей, «перевыпустить» ключ = удалить и создать заново.
|
||||
|
||||
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; `0` — сервер
|
||||
самой панели, остальные — подключённые ноды). Пользователи `vpn://` заводятся на сервере ключа
|
||||
по умолчанию; другой сервер из набора выбирается заголовком `X-Server-Id: <id>`, сервер вне
|
||||
набора → `404`.
|
||||
|
||||
#### Мастер-ключи `sen://`
|
||||
|
||||
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое устройство
|
||||
само регистрируется по ссылке и получает пира на всех серверах ключа, его приватный ключ сервер
|
||||
не видит. API-ключ видит и меняет мастер-ключ, только если **все** его серверы входят в набор
|
||||
API-ключа; остальные для него не существуют (`404`).
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
|
||||
-H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
|
||||
-d '{"label":"Семья","device_limit":3}' | jq -r .link
|
||||
```
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, uuid, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
|
||||
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`. `uuid` — постоянный идентификатор ключа: не меняется ни при перевыпуске ссылки, ни при правке метки. Везде, где в пути `:id`, можно передать и `uuid` |
|
||||
| `GET /api/v1/masterkeys/:id` | Ключ, ссылка и устройства одним запросом: те же поля + `link`, `tls`, `device_list` (`devices` — счётчик) |
|
||||
| `PATCH /api/v1/masterkeys/:id` | `{ label?, device_limit?, servers? }` — смена серверов сразу добавляет/снимает пиров устройств |
|
||||
| `POST /api/v1/masterkeys/:id/rotate` | Новая ссылка `{ id, link, tls }`; старая не принимает новые устройства, подключённые работают дальше |
|
||||
| `POST /api/v1/masterkeys/:id/rekey` | Все устройства сменят ключи WireGuard при следующем опросе (IP и PSK сохраняются) |
|
||||
| `DELETE /api/v1/masterkeys/:id` | `{ success, id, pending }` — ключ и устройства отключаются сразу; `pending` — серверы (ноды не в сети), которые ещё не сняли пиров, до тех пор ключ в списке с `deleting` |
|
||||
| `GET /api/v1/masterkeys/:id/devices` | `{ devices: [{ id, device_name, platform, version, online, lastHandshake, rx, tx, last_seen, servers_ok, servers_total, … }] }` — статистика по всем серверам ключа |
|
||||
| `DELETE /api/v1/masterkeys/:id/devices/:device` | Отвязывает устройство, место освобождается |
|
||||
| `POST /api/v1/masterkeys/:id/devices/:device/rekey` | Устройство сменит ключ при следующем опросе |
|
||||
| `POST /api/v1/masterkeys/:id/devices/:device/psk` | Новый PSK: `{ id, pending }` — `pending` серверов ждут ноду |
|
||||
|
||||
| Код | Когда |
|
||||
|---|---|
|
||||
| `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` |
|
||||
| `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен |
|
||||
| `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает |
|
||||
|
||||
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`), поэтому переустановка их
|
||||
не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей
|
||||
внутренней авторизацией. Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys`
|
||||
(с полем `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
|
||||
перевыпуская ключ, и `DELETE /ui/apikeys/:id`.
|
||||
|
||||
### Авторизация панели
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user":"admin","pass":"***"}' | jq -r .token)
|
||||
```
|
||||
|
||||
| Метод | Ответ и ошибки |
|
||||
|---|---|
|
||||
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
|
||||
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
|
||||
|
||||
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`; сервер — заголовком `X-Server-Id`
|
||||
(без него — сервер по умолчанию). Ниже — роуты, которые дёргает сама панель; в отличие от
|
||||
`/api/v1` их форма может меняться.
|
||||
|
||||
### Состояние
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `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 /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: `"2"` или
|
||||
`"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
|
||||
|
||||
### Пользователи
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `POST /api/users` | Создаёт пира. Тело `{ "name": "alice" }`, имя — `a–z A–Z 0–9 _ -`, до 32 символов. `201` с `{ name, ip, pub_key, psk_key, vpn_key, key_gen, vpn_key_prev }`; `400` — имя не прошло валидацию, `409` — уже есть |
|
||||
| `GET /api/users` | Список: `{ users: [{ name, ip, pub_key, vpn_key, key_gen, online, lastHandshake }] }` |
|
||||
| `GET /api/users/stats` | То же без ключей, но со счётчиками: `{ users: [{ name, ip, key_gen, online, lastHandshake, rx, tx }] }` |
|
||||
| `POST /api/users/:name` | Возвращает пользователя целиком, включая `vpn_key`. Метод именно `POST`, чтобы ключ не оседал в логах и истории как параметр `GET`. `404` — не найден |
|
||||
| `DELETE /api/users/:name` | `{ "success": true, "name": "alice" }` |
|
||||
| `POST /api/users/reissue` | Пересобирает `vpn://` всем на текущих параметрах: `{ total, reissued, regenerated: [], backup }`. IP и ключевая пара сохраняются, клиентам нужно заново импортировать ключ; `backup` — путь к снимку базы, снятому перед проходом |
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://HOST:PORT/api/users \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"name":"alice"}' | jq -r .vpn_key
|
||||
```
|
||||
|
||||
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
|
||||
base64url-декодировать, отбросить первые 4 байта (длина), распаковать `zlib inflate` → JSON;
|
||||
текст конфига лежит в `last_config.config`.
|
||||
|
||||
### Журнал подключений
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/connlog` | Журнал сервера из `X-Server-Id`: `{ events: [{ id, at, event, name, owner, ip, key?, device?, duration?, rx?, tx? }], peers: [{ name, owner, ip, online, last_seen, key?, device? }], since, keep_days }`. `event` — `connect` или `disconnect` (у него `duration`, `rx`, `tx` — со стороны сервера); `key`/`device` — мастер-ключ и устройство `sen://`. Параметры: `limit` (до 500), `before=<id>` — следующая страница, `q` — поиск по имени, владельцу, IP. `501` — сервер старой версии |
|
||||
|
||||
Без имён `sen://` тот же журнал отдаёт awg-ctrl: `GET /api/connlog`.
|
||||
|
||||
### Интерфейс AWG
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `POST /awg/start` | Поднимает интерфейс, если он лежит; возвращает его статус |
|
||||
| `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся |
|
||||
| `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` |
|
||||
|
||||
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не 3.x или ядро
|
||||
старше 5.5, и `500` с текстом причины, если ядро не приняло 3.1-конфиг — в этом случае прежний
|
||||
конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0.
|
||||
|
||||
### Мастер-ключи
|
||||
|
||||
Те же операции, что в `/api/v1/masterkeys`, но без ограничения набором серверов.
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `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`; без `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? }` |
|
||||
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
|
||||
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
|
||||
| `POST /ui/masterkeys/:id/rotate`, `/rekey` | Новая ссылка / смена ключей у всех устройств |
|
||||
| `DELETE /ui/masterkeys/:id` | `{ success, id, pending }` |
|
||||
| `DELETE /ui/devices/:id` | Отвязать устройство |
|
||||
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
|
||||
|
||||
### Метрики
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `GET /ui/metrics-token` | `{ exists, prefix?, created_at?, last_used?, per_peer }` — токен для своего Prometheus |
|
||||
| `POST /ui/metrics-token` | Выпустить или перевыпустить: `201` с `token` (показывается один раз); старый перестаёт работать |
|
||||
| `DELETE /ui/metrics-token` | Отозвать |
|
||||
| `GET /metrics` | Экспозиция Prometheus по всем серверам; `Authorization: Bearer awgm_…` |
|
||||
|
||||
## Управление
|
||||
|
||||
```bash
|
||||
awg-ctrl
|
||||
awg-ctrl # меню
|
||||
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 (автозапуск на загрузке):
|
||||
|
||||
```bash
|
||||
systemctl start|stop|restart awg-control
|
||||
```
|
||||
|
||||
## 1. Публичный API (`/api/v1`)
|
||||
### Настройки `cli.env`
|
||||
|
||||
Авторизация по API-ключу. Ответы содержат только публичные поля — приватные
|
||||
ключи и PSK наружу не отдаются.
|
||||
`/opt/awg-control/cli/cli.env` пишет установщик; после правки — `awg-ctrl restart`. Чаще всего
|
||||
нужны:
|
||||
|
||||
### Авторизация
|
||||
|
||||
Каждый запрос обязан содержать заголовок:
|
||||
|
||||
```
|
||||
X-Api-Key: awgk_<...>
|
||||
```
|
||||
|
||||
Ключ создаётся в панели (вкладка «API-ключи») или через `POST /ui/apikeys`
|
||||
(раздел 3). Открытое значение ключа показывается **один раз** при создании —
|
||||
сохрани его сразу, дальше доступен только хэш.
|
||||
|
||||
Ответы при проблемах с ключом:
|
||||
|
||||
| Код | Когда |
|
||||
| Переменная | Что делает |
|
||||
|---|---|
|
||||
| `401 {"error":"API key required"}` | заголовок отсутствует или не начинается с `awgk_` |
|
||||
| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) |
|
||||
| `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` берут установщик (своё зеркало) |
|
||||
|
||||
> Ключ привязан к серверу (`server_id`). Сейчас сервер один — поле всегда `0`
|
||||
|
||||
### `POST /api/v1/users` — создать пользователя
|
||||
|
||||
Создаёт VPN-пира и возвращает готовый `vpn://` ключ.
|
||||
|
||||
**Тело:** `{ "name": "<имя>" }`
|
||||
Имя: `a–z A–Z 0–9 _ -`, 1–32 символа.
|
||||
|
||||
**`201 Created`:**
|
||||
```json
|
||||
{
|
||||
"name": "alice",
|
||||
"ip": "10.9.0.2",
|
||||
"vpn_key": "vpn://AAAI..."
|
||||
}
|
||||
```
|
||||
| Код ошибки | Причина |
|
||||
|---|---|
|
||||
| `400` | имя не прошло валидацию |
|
||||
| `409` | пользователь с таким именем уже есть |
|
||||
```bash
|
||||
curl -X POST http://HOST:PORT/api/v1/users \
|
||||
-H "X-Api-Key: awgk_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"alice"}'
|
||||
```
|
||||
### `GET /api/v1/users` — список пользователей
|
||||
Пиры с трафиком и статусом онлайн.
|
||||
**`200 OK`:**
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{ "name": "alice", "ip": "10.9.0.2", "online": true, "lastHandshake": 1718800000, "rx": 142400000, "tx": 9800000 },
|
||||
{ "name": "bob", "ip": "10.9.0.3", "online": false, "lastHandshake": 0, "rx": 0, "tx": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
- `lastHandshake` — unix-секунды последнего рукопожатия (`0` — не было).
|
||||
- `rx`/`tx` — байты принято/отдано.
|
||||
|
||||
```bash
|
||||
curl http://HOST:PORT/api/v1/users -H "X-Api-Key: awgk_xxx"
|
||||
```
|
||||
|
||||
### `GET /api/v1/users/:name` — получить ключ пользователя
|
||||
|
||||
**`200 OK`:**
|
||||
```json
|
||||
{ "name": "alice", "ip": "10.9.0.2", "vpn_key": "vpn://AAAI..." }
|
||||
```
|
||||
|
||||
| Код ошибки | Причина |
|
||||
|---|---|
|
||||
| `404` | пользователь не найден |
|
||||
|
||||
```bash
|
||||
curl http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
|
||||
```
|
||||
|
||||
> Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс
|
||||
> `vpn://`, base64url-декодировать, отбросить первые 4 байта (длина), распаковать
|
||||
> `zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
|
||||
|
||||
### `DELETE /api/v1/users/:name` — удалить пользователя
|
||||
|
||||
**`200 OK`:**
|
||||
```json
|
||||
{ "success": true, "name": "alice" }
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
|
||||
```
|
||||
## Лицензия
|
||||
[MIT](LICENSE) © 2026 Ivan Vasilev
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Admin — каркас админ-панели
|
||||
|
||||
Самодостаточный шаблон админки: бэкенд с авторизацией и фронтенд-оболочка
|
||||
с системой вкладок. Предметной логики нет — это основа, поверх которой
|
||||
навешиваются свои роуты и вкладки.
|
||||
|
||||
```
|
||||
admin/
|
||||
backend/ Express: /login, /logout, JWT, rate-limit, отдача SPA
|
||||
server.ts
|
||||
frontend/ React 18 + Vite: логин, тема, drawer/сайдбар, реестр вкладок
|
||||
src/App.tsx оболочка
|
||||
src/App.css дизайн-система (Material 3, тёплая палитра)
|
||||
src/tabs/ вкладки — см. src/tabs/README.md
|
||||
src/components/ общие компоненты и иконки (+ карта ICONS)
|
||||
src/lib/shared.ts общие типы/утилиты + PageProps
|
||||
src/lib/branding.ts название и версия панели
|
||||
```
|
||||
|
||||
## Запуск в деве
|
||||
|
||||
Два терминала:
|
||||
|
||||
```bash
|
||||
cd admin/backend && npm install && ADMIN_PASS=admin npm start # :8080
|
||||
cd admin/frontend && npm install && npm run dev # :5173
|
||||
```
|
||||
|
||||
Открыть http://localhost:5173 — логин `admin` / `admin`.
|
||||
Vite проксирует `/api`, `/login`, `/logout` на бэкенд, поэтому фронт всегда
|
||||
ходит по относительным путям и порт нигде не зашит.
|
||||
|
||||
## Прод
|
||||
|
||||
```bash
|
||||
cd admin/frontend && npm run build # → frontend/dist
|
||||
cd admin/backend && npm start # раздаёт dist и API с одного порта
|
||||
```
|
||||
|
||||
## Переменные окружения (backend)
|
||||
|
||||
| Переменная | По умолчанию | Смысл |
|
||||
|---------------|---------------------------|--------------------------------------------------------------|
|
||||
| `PORT` | `8080` | Порт бэкенда |
|
||||
| `ADMIN_USER` | `admin` | Логин в панель |
|
||||
| `ADMIN_PASS` | — (пусто ⇒ вход отключён) | Пароль; без него `/login` всегда отвечает 401 |
|
||||
| `JWT_SECRET` | случайный при старте | Подпись токенов; без него сессии рвутся при рестарте процесса |
|
||||
| `STATIC_DIR` | `../frontend/dist` | Каталог со сборкой фронта |
|
||||
|
||||
## Авторизация
|
||||
|
||||
`POST /login` (логин + пароль) → HS256 JWT на 24 часа, подписанный `JWT_SECRET`
|
||||
через встроенный `crypto` — без сторонних JWT-библиотек. Токен хранится в
|
||||
`localStorage` и уходит в заголовке `Authorization: Bearer`. `POST /logout`
|
||||
добавляет подпись токена в in-memory список отозванных — токен умирает сразу.
|
||||
Перебор пароля ограничен: 5 неудачных попыток с одного IP за 15 минут → 429.
|
||||
|
||||
Всё под `/api` закрыто мидлварой `requireAuth` — свои роуты добавляй ниже
|
||||
строки `app.use("/api", requireAuth)` в `server.ts`, и они получают авторизацию
|
||||
даром. `GET /api/session` уже есть: фронт зовёт его при загрузке, чтобы понять,
|
||||
жив ли сохранённый токен.
|
||||
|
||||
## Как добавить свой раздел
|
||||
|
||||
1. Роут на бэкенде — в `server.ts` под `/api/…`.
|
||||
2. Вкладку на фронте — папка в `src/tabs/`, реестр подхватит её сам.
|
||||
Подробности и формат `metadata.json` — в [src/tabs/README.md](frontend/src/tabs/README.md).
|
||||
Generated
+1491
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "admin-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "tsx server.ts",
|
||||
"dev": "tsx watch server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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.
|
||||
// Бэкенд админ-панели: логин, JWT-сессия, отдача собранного SPA.
|
||||
// Никакой предметной логики здесь нет — это каркас, поверх которого
|
||||
// навешиваются свои роуты под /api (они автоматически за requireAuth).
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
|
||||
const ADMIN_USER = process.env.ADMIN_USER ?? "admin";
|
||||
const ADMIN_PASS = process.env.ADMIN_PASS ?? "";
|
||||
// Без заданного секрета токены живут только до рестарта процесса — для дева
|
||||
// это удобно, в проде JWT_SECRET обязателен (иначе сессии рвутся при рестарте).
|
||||
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// Каталог со сборкой фронта (vite build → frontend/dist).
|
||||
const STATIC_DIR = process.env.STATIC_DIR ?? path.join(__dirname, "..", "frontend", "dist");
|
||||
|
||||
const TOKEN_TTL = 24 * 60 * 60; // сек
|
||||
|
||||
// Отозванные токены (logout). In-memory: рестарт процесса и так рвёт сессии,
|
||||
// потому что при пустом JWT_SECRET секрет генерится заново.
|
||||
const revoked = new Set<string>();
|
||||
|
||||
function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
|
||||
|
||||
function sign(user: string): string {
|
||||
const h = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url");
|
||||
const p = Buffer.from(JSON.stringify({
|
||||
sub: user,
|
||||
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
|
||||
})).toString("base64url");
|
||||
const s = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url");
|
||||
return `${h}.${p}.${s}`;
|
||||
}
|
||||
|
||||
interface Claims { sub?: string; exp: number; }
|
||||
|
||||
function verify(token: string): Claims | null {
|
||||
try {
|
||||
if (revoked.has(tokenSig(token))) return null;
|
||||
const [h, p, s] = token.split(".");
|
||||
if (!h || !p || !s) return null;
|
||||
const expected = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest();
|
||||
const actual = Buffer.from(s, "base64url");
|
||||
if (actual.length !== expected.length) return null;
|
||||
if (!crypto.timingSafeEqual(actual, expected)) return null;
|
||||
const claims = JSON.parse(Buffer.from(p, "base64url").toString()) as Claims;
|
||||
if (claims.exp <= Math.floor(Date.now() / 1000)) return null;
|
||||
return claims;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
interface AuthRequest extends Request { claims?: Claims; }
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = (req.headers["authorization"] ?? "") as string;
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||
const claims = token ? verify(token) : null;
|
||||
if (!claims) { res.status(401).json({ error: "Unauthorized" }); return; }
|
||||
(req as AuthRequest).claims = claims;
|
||||
next();
|
||||
}
|
||||
|
||||
// Ограничение перебора пароля: 5 неудачных попыток на IP за 15 минут.
|
||||
const RATE_LIMIT = 5;
|
||||
const RATE_WINDOW = 15 * 60 * 1000;
|
||||
|
||||
interface RateEntry { count: number; resetAt: number; }
|
||||
const loginAttempts = new Map<string, RateEntry>();
|
||||
|
||||
function checkRate(ip: string): { ok: boolean; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(ip);
|
||||
if (!entry || entry.resetAt < now) {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW });
|
||||
return { ok: true };
|
||||
}
|
||||
if (entry.count >= RATE_LIMIT) {
|
||||
return { ok: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
||||
}
|
||||
entry.count++;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/login", (req: Request, res: Response) => {
|
||||
const ip = req.socket.remoteAddress ?? "unknown";
|
||||
const { ok, retryAfter } = checkRate(ip);
|
||||
if (!ok) {
|
||||
res.status(429).json({ error: `Слишком много попыток. Повтори через ${retryAfter} сек.` }); return;
|
||||
}
|
||||
const { user, pass } = req.body as { user?: string; pass?: string };
|
||||
if (!ADMIN_PASS || user !== ADMIN_USER || pass !== ADMIN_PASS) {
|
||||
res.status(401).json({ error: "Неверный логин или пароль" }); return;
|
||||
}
|
||||
loginAttempts.delete(ip);
|
||||
res.json({ token: sign(ADMIN_USER) });
|
||||
});
|
||||
|
||||
app.post("/logout", requireAuth, (req: Request, res: Response) => {
|
||||
const token = (req.headers["authorization"] as string).slice(7);
|
||||
revoked.add(tokenSig(token));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Всё под /api закрыто JWT — свои роуты добавляй ниже этой строки.
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
// Проверка живости сессии: фронт зовёт его при загрузке, чтобы понять,
|
||||
// валиден ли сохранённый токен.
|
||||
app.get("/api/session", (req: Request, res: Response) => {
|
||||
res.json({ user: (req as AuthRequest).claims?.sub ?? null });
|
||||
});
|
||||
|
||||
// Статика SPA + fallback на index.html (роутинг на клиенте).
|
||||
app.use(express.static(STATIC_DIR));
|
||||
|
||||
app.get("*", (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(STATIC_DIR, "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
if (!ADMIN_PASS) console.warn("admin: ADMIN_PASS не задан — вход отключён");
|
||||
console.log(`admin: listening on :${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["server.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2096
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "admin-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
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. Здесь только общий язык (палитра, кнопки, поля, чипы,
|
||||
таблица, модалка, снэкбар) — стили конкретной вкладки живут в её папке
|
||||
(tabs/<name>/<name>.css) и импортятся её же компонентом.
|
||||
*/
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
:root {
|
||||
--primary: #6a45b8;
|
||||
--on-primary: #ffffff;
|
||||
--primary-container: #ebddff;
|
||||
--on-primary-container: #5a2da8;
|
||||
|
||||
--surface: #faf6f0;
|
||||
--surface-bright: #fdfaf6;
|
||||
--surface-container: #f0e8dd;
|
||||
--surface-hi: #ece2d3;
|
||||
--on-surface: #3a2e22;
|
||||
|
||||
--outline: #d8cdbe;
|
||||
--outline-variant: #ece2d3;
|
||||
|
||||
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
|
||||
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
||||
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
||||
|
||||
--danger-border: #e6c4bc;
|
||||
--danger-text: #b34a3a;
|
||||
|
||||
/* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */
|
||||
--label: #5a4a3a;
|
||||
|
||||
/* state layers / тени — зависят от темы */
|
||||
--state-primary: rgba(106, 69, 184, .10);
|
||||
--state-row: rgba(106, 69, 184, .035);
|
||||
--state-neutral: rgba(58, 46, 34, .08);
|
||||
--scrim: rgba(58, 46, 34, .45);
|
||||
--shadow: rgba(58, 46, 34, .15);
|
||||
|
||||
--font: system-ui, -apple-system, 'Inter', sans-serif;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
|
||||
--r-pill: 100px;
|
||||
--r-card: 24px;
|
||||
--r-table: 16px;
|
||||
--r-logo: 9px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--primary: #cbb3ff;
|
||||
--on-primary: #381b6c;
|
||||
--primary-container: #52389e;
|
||||
--on-primary-container: #ebddff;
|
||||
|
||||
--surface: #1a1611;
|
||||
--surface-bright: #2b251d;
|
||||
--surface-container: #251f18;
|
||||
--surface-hi: #302820;
|
||||
--on-surface: #ede2d4;
|
||||
|
||||
--outline: #4d4334;
|
||||
--outline-variant: #342c20;
|
||||
|
||||
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
|
||||
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
||||
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
||||
|
||||
--danger-border: #6e4540;
|
||||
--danger-text: #ffb4ab;
|
||||
|
||||
--label: #b6a994;
|
||||
|
||||
--state-primary: rgba(203, 179, 255, .14);
|
||||
--state-row: rgba(203, 179, 255, .05);
|
||||
--state-neutral: rgba(237, 226, 212, .08);
|
||||
--scrim: rgba(0, 0, 0, .55);
|
||||
--shadow: rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; }
|
||||
|
||||
.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
.login-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
}
|
||||
.login-card {
|
||||
width: 352px;
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-card);
|
||||
border: 1px solid var(--outline-variant);
|
||||
padding: 36px 32px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.login-sub {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-error {
|
||||
font-size: 13px;
|
||||
color: var(--danger-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px solid var(--outline);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-bright);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.field:focus { border-color: var(--primary); }
|
||||
.field::placeholder { color: var(--neutral-dot); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn:hover { opacity: 0.88; }
|
||||
.btn:active { opacity: 0.74; }
|
||||
|
||||
.btn--primary { background: var(--primary); color: var(--on-primary); }
|
||||
.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; }
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--outline);
|
||||
color: var(--primary);
|
||||
}
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--danger-border);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
.btn--full { width: 100%; }
|
||||
.btn--sq { width: 44px; padding: 0; flex-shrink: 0; }
|
||||
|
||||
/* icon-only circular button */
|
||||
.btn-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
transition: background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { background: var(--state-primary); }
|
||||
.btn-icon--danger { color: var(--danger-text); }
|
||||
.btn-icon--danger:hover { background: rgba(179,74,58,.10); }
|
||||
.btn-icon--neutral { color: var(--neutral-text); }
|
||||
.btn-icon--neutral:hover { background: var(--state-neutral); }
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 250;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--outline);
|
||||
background: var(--surface-bright);
|
||||
color: var(--on-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.top-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 56px;
|
||||
padding: 0 8px 0 4px;
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.hamburger-btn {
|
||||
width: 48px; height: 48px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--on-surface);
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hamburger-btn:active { background: var(--state-neutral); }
|
||||
.top-bar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.drawer-scrim {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--scrim);
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
.drawer-scrim--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-container);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 12px;
|
||||
gap: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 0 8px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sidebar-version {
|
||||
font-size: 11px;
|
||||
color: var(--neutral-dot);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.drawer-divider {
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--outline);
|
||||
margin: 0 -12px 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--label);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item--active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; }
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip--online { background: var(--success-bg); color: var(--success-text); }
|
||||
.chip--online .chip-dot { background: var(--success-dot); }
|
||||
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||
.chip--offline .chip-dot { background: var(--neutral-dot); }
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 32px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
/* Заглушка на месте контента — когда вкладок ещё нет */
|
||||
.placeholder {
|
||||
color: var(--neutral-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.placeholder code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--surface-container);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Тултип без задержки: нативный title браузер показывает через ~500 мс */
|
||||
.tip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.tip-wrap::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--on-surface);
|
||||
color: var(--surface-bright);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tip-wrap:hover::after { opacity: 1; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar .field {
|
||||
width: 224px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* На десктопе кнопки участвуют в flex toolbar'а напрямую */
|
||||
.toolbar-btns { display: contents; }
|
||||
|
||||
.table-card {
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-table);
|
||||
border: 1px solid var(--outline-variant);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: var(--surface-container);
|
||||
color: var(--label);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
thead th:first-child { padding-left: 20px; }
|
||||
thead th:last-child { padding-right: 20px; }
|
||||
|
||||
tbody tr { border-top: 0.5px solid var(--outline-variant); }
|
||||
tbody tr:hover { background: var(--state-row); }
|
||||
|
||||
tbody td {
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody td:first-child { padding-left: 20px; }
|
||||
tbody td:last-child { padding-right: 20px; }
|
||||
|
||||
.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; }
|
||||
.td-mono { font-family: var(--mono); font-size: 12px; }
|
||||
.td-actions { width: 96px; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 2px; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--neutral-text);
|
||||
padding: 48px 0 !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Модалка — общий каркас (backdrop + карточка). Содержимое даёт вкладка. */
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: scale(.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--scrim);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 300;
|
||||
animation: backdrop-in 0.18s ease;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-card);
|
||||
border: 1px solid var(--outline-variant);
|
||||
padding: 28px 28px 24px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
width: 320px;
|
||||
animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1);
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.modal-hint { font-size: 12px; color: var(--neutral-text); text-align: center; }
|
||||
.modal-actions { display: flex; gap: 8px; width: 100%; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
.snack {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--on-surface);
|
||||
color: var(--surface-bright);
|
||||
font-size: 13px;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--r-pill);
|
||||
z-index: 500;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 16px var(--shadow);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Top bar */
|
||||
.top-bar { display: flex; }
|
||||
.page-title { display: none; }
|
||||
|
||||
/* Drawer scrim */
|
||||
.drawer-scrim { display: block; }
|
||||
|
||||
/* Запретить горизонтальный скролл страницы */
|
||||
body, .app { overflow-x: hidden; }
|
||||
|
||||
/* Sidebar → модальный drawer */
|
||||
.layout {
|
||||
min-height: 0; /* flex: 1 на .app справится сам */
|
||||
overflow: visible;
|
||||
}
|
||||
/* main — не скролл-контейнер, пусть скроллит body */
|
||||
.main { overflow: visible; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 280px;
|
||||
z-index: 400;
|
||||
border-radius: 0 var(--r-card) var(--r-card) 0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1);
|
||||
box-shadow: 4px 0 24px var(--shadow);
|
||||
padding-top: 36px;
|
||||
}
|
||||
.sidebar--open { transform: translateX(0); }
|
||||
|
||||
/* Main: одна колонка, поля 16px */
|
||||
.main {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Toolbar → вертикально */
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar .field { width: 100%; }
|
||||
.toolbar-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-btns .btn { flex: 1; }
|
||||
|
||||
/* Таблицы на мобильном заменяются карточками — их даёт сама вкладка */
|
||||
.table-card { display: none; }
|
||||
|
||||
/* Модалка — по ширине экрана */
|
||||
.modal-card {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 360px;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 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, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import { TOKEN_KEY, THEME_KEY } from './lib/shared';
|
||||
import { APP_NAME, APP_VERSION } from './lib/branding';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from './components/icons';
|
||||
import { TABS } from './tabs';
|
||||
|
||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||
// снэкбар) и переключение вкладок. Контент каждой вкладки приходит из реестра
|
||||
// TABS — App про конкретные вкладки ничего не знает.
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(() => TABS[0]?.id ?? '');
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
||||
const touchStartX = useRef(0);
|
||||
const touchCurrentX = useRef(0);
|
||||
|
||||
const showMsg = useCallback((text: string) => {
|
||||
setMsg(text);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}, []);
|
||||
|
||||
// Проверяет, жив ли токен. Данные конкретных вкладок грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
await axios.get('/api/session', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
|
||||
const tok = auth.token as string;
|
||||
await startSession(tok);
|
||||
localStorage.setItem(TOKEN_KEY, tok);
|
||||
setLoginError('');
|
||||
setToken(tok);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
||||
showMsg('Неверный логин или пароль');
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 429) {
|
||||
showMsg(e.response.data?.error ?? 'Слишком много попыток');
|
||||
} else {
|
||||
setLoginError('Сервер недоступен');
|
||||
}
|
||||
}
|
||||
}, [loginUser, loginPass, startSession, showMsg]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await axios.post('/logout', null, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch { /* токен уже мог истечь */ }
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setDrawerOpen(false);
|
||||
}, [token]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Восстановить сессию из сохранённого токена
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY);
|
||||
if (saved) {
|
||||
startSession(saved).catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
});
|
||||
}
|
||||
}, [startSession]);
|
||||
|
||||
// Применять и сохранять тему
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
// Блокировать прокрутку фона когда drawer открыт
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = drawerOpen ? 'hidden' : '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [drawerOpen]);
|
||||
|
||||
// Закрывать drawer по Escape
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); };
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [drawerOpen]);
|
||||
|
||||
// Вкладок может не быть вовсе — тогда вместо контента показываем заглушку.
|
||||
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<button
|
||||
className="theme-toggle"
|
||||
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
|
||||
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
|
||||
>
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
|
||||
{!token ? (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<span className="logo-name">{APP_NAME}</span>
|
||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Логин"
|
||||
value={loginUser}
|
||||
onChange={e => setLoginUser(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={loginPass}
|
||||
onChange={e => setLoginPass(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && login()}
|
||||
/>
|
||||
<button className="btn btn--primary btn--full" onClick={login}>
|
||||
Войти
|
||||
</button>
|
||||
{loginError && <p className="login-error">{loginError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Top bar — видим только на мобильном */}
|
||||
<header className="top-bar">
|
||||
<button
|
||||
className="hamburger-btn"
|
||||
aria-label="Открыть меню"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<IcoMenu />
|
||||
</button>
|
||||
<span className="top-bar-title">{tab?.label ?? APP_NAME}</span>
|
||||
</header>
|
||||
|
||||
{/* Затемнение под drawer */}
|
||||
<div
|
||||
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="layout">
|
||||
<aside
|
||||
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="sidebar-logo">
|
||||
<span className="logo-name">{APP_NAME}</span>
|
||||
<span className="sidebar-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{TABS.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === tab?.id ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<button className="btn btn--danger" onClick={logout}>
|
||||
<IcoLogout /> Выйти
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<h2 className="page-title">{tab?.label ?? APP_NAME}</h2>
|
||||
{tab
|
||||
? <tab.Page token={token} showMsg={showMsg} />
|
||||
: <p className="placeholder">
|
||||
Вкладок пока нет — добавь папку в <code>src/tabs/</code>
|
||||
</p>}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
|
||||
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
|
||||
|
||||
export const IcoMenu = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoUsers = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoKey = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5"/>
|
||||
<path d="M10.7 12.3 21 2"/><path d="M16 7l3 3"/><path d="M18 5l2 2"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoSettings = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoPlus = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoRefresh = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoTrash = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoLogout = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoGlobe = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoCopy = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoSun = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoMoon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
|
||||
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
users: IcoUsers,
|
||||
key: IcoKey,
|
||||
settings: IcoSettings,
|
||||
globe: IcoGlobe,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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.
|
||||
// Название и версия панели — показываются в шапке сайдбара и на экране логина.
|
||||
// Единственное место, где зашито «имя продукта»: под свой проект правится тут.
|
||||
export const APP_NAME = 'Admin';
|
||||
export const APP_VERSION = 'Alpha 0.1.0';
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 axios from 'axios';
|
||||
|
||||
// Пропсы, которые оболочка передаёт каждой вкладке. Всё остальное (свои данные,
|
||||
// свой стейт) вкладка берёт сама.
|
||||
export interface PageProps {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
}
|
||||
|
||||
export const TOKEN_KEY = 'admin_token';
|
||||
export const THEME_KEY = 'admin_theme';
|
||||
|
||||
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
||||
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export function downloadFile(filename: string, text: string) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function copyText(text: string) {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export function bytes(n: number | undefined): string {
|
||||
if (!n) return '—';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
|
||||
return (n / 1_073_741_824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
// unix-секунды → «5 мин» / «3 ч» (без суффикса «назад» — его добавляет вызывающий)
|
||||
export function timeAgo(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const sec = Math.floor(Date.now() / 1000) - ts;
|
||||
if (sec < 60) return sec + ' сек';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
|
||||
return Math.floor(sec / 3600) + ' ч';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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 { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
# Вкладки
|
||||
|
||||
Вкладок пока нет — каркас работает и с пустой папкой (оболочка покажет заглушку).
|
||||
|
||||
## Как добавить вкладку
|
||||
|
||||
Создай папку `src/tabs/<name>/` с тремя файлами — реестр (`tabs/index.ts`)
|
||||
подхватит её сам, править его **не нужно**.
|
||||
|
||||
```
|
||||
src/tabs/reports/
|
||||
metadata.json { "id": "reports", "label": "Отчёты", "icon": "settings", "order": 1 }
|
||||
index.tsx export default function ReportsPage({ token, showMsg }: PageProps) { … }
|
||||
reports.css стили только этой вкладки (импортит сам index.tsx)
|
||||
```
|
||||
|
||||
**metadata.json**
|
||||
|
||||
| поле | тип | обяз. | смысл |
|
||||
|---------|--------|-------|----------------------------------------------------|
|
||||
| `id` | string | да | ключ активной вкладки, должен быть уникальным |
|
||||
| `label` | string | да | подпись в меню и заголовок раздела |
|
||||
| `icon` | string | да | имя из `ICONS` в `src/components/icons.tsx` |
|
||||
| `order` | number | нет | порядок в меню по возрастанию (без него — 0) |
|
||||
|
||||
Манифест валидируется в рантайме: опечатка или незарегистрированная иконка
|
||||
падают с внятной ошибкой при сборке `TABS`, а не «тихо» в UI.
|
||||
|
||||
**index.tsx** — дефолтный экспорт компонента `ComponentType<PageProps>`,
|
||||
где `PageProps = { token, showMsg }` (`src/lib/shared.ts`). Данные вкладка
|
||||
грузит сама: `apiFetch('GET', '/api/…', token)`.
|
||||
|
||||
Новую иконку сначала добавь в `src/components/icons.tsx` и зарегистрируй
|
||||
в карте `ICONS` — на её имя и ссылается `metadata.json`.
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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.
|
||||
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||
// index.tsx — компонент + логика
|
||||
// <name>.css — стили вкладки (импортит сам компонент)
|
||||
// metadata.json — { id, label, icon, order? } (манифест: описание + порядок)
|
||||
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
||||
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
||||
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
||||
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
|
||||
// валидируется здесь в рантайме — кривой/неполный манифест падает с внятной
|
||||
// ошибкой при сборке TABS, а не «тихо» в UI.
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PageProps } from '../lib/shared';
|
||||
import { ICONS } from '../components/icons';
|
||||
|
||||
// Сериализуемая часть из metadata.json.
|
||||
export interface TabMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // имя иконки → ICONS
|
||||
order?: number; // порядок в меню (по возрастанию; без него — 0)
|
||||
}
|
||||
|
||||
// Собранная вкладка: метадата + резолвнутые Icon и Page.
|
||||
export interface TabDef extends TabMeta {
|
||||
Icon: () => JSX.Element;
|
||||
Page: ComponentType<PageProps>;
|
||||
}
|
||||
|
||||
const metas = import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true });
|
||||
const pages = import.meta.glob<{ default: ComponentType<PageProps> }>('./*/index.tsx', { eager: true });
|
||||
|
||||
// './users/metadata.json' → 'users'
|
||||
const folderOf = (path: string) => path.split('/')[1];
|
||||
|
||||
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
|
||||
function toMeta(name: string, raw: unknown): TabMeta {
|
||||
const m = (raw ?? {}) as Record<string, unknown>;
|
||||
const str = (key: string): string => {
|
||||
const v = m[key];
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
throw new Error(`tabs/${name}/metadata.json: поле "${key}" должно быть непустой строкой`);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
if (m.order !== undefined && typeof m.order !== 'number') {
|
||||
throw new Error(`tabs/${name}/metadata.json: "order" должно быть числом`);
|
||||
}
|
||||
return {
|
||||
id: str('id'),
|
||||
label: str('label'),
|
||||
icon: str('icon'),
|
||||
order: m.order as number | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка
|
||||
// показывает заглушку вместо контента.
|
||||
export const TABS: TabDef[] = Object.entries(metas)
|
||||
.map(([path, mod]) => {
|
||||
const name = folderOf(path);
|
||||
const meta = toMeta(name, mod.default);
|
||||
const page = pages[`./${name}/index.tsx`]?.default;
|
||||
if (!page) {
|
||||
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
|
||||
}
|
||||
if (!ICONS[meta.icon]) {
|
||||
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS (components/icons.tsx)`);
|
||||
}
|
||||
return { ...meta, Icon: ICONS[meta.icon], Page: page };
|
||||
})
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
||||
const ids = TABS.map(t => t.id);
|
||||
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
if (dup) {
|
||||
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
// 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.
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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 { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// В деве Vite (:5173) проксирует запросы на бэкенд (:8080) — фронт всегда
|
||||
// ходит по относительным путям, порт нигде не зашит.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/login': 'http://localhost:8080',
|
||||
'/logout': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// 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.
|
||||
//
|
||||
// Агент ноды. Сам открывает исходящее WSS-соединение к core (awg-ui), доказывает
|
||||
// свою личность подписью Ed25519 и исполняет присланные запросы на локальном
|
||||
// awg-ctrl (127.0.0.1). Входящих портов у ноды нет, awg-ctrl остаётся закрытым от сети.
|
||||
// Протокол и формат join-строки — docs/node-protocol.md.
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import type { TLSSocket } from "tls";
|
||||
import axios from "axios";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const CORE_HOST = process.env.CORE_HOST ?? "";
|
||||
const CORE_PORT = Number(process.env.CORE_PORT) || 0;
|
||||
const CORE_PIN = process.env.CORE_PIN ?? ""; // SHA-256 SPKI сертификата core, base64url
|
||||
const NODE_ID = Number(process.env.NODE_ID) || 0;
|
||||
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
|
||||
const NODE_KEY_FILE = process.env.NODE_KEY_FILE ?? path.join(__dirname, "node.key");
|
||||
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnezia/amneziawg/internal_auth_private.key";
|
||||
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
|
||||
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
|
||||
|
||||
// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из
|
||||
// .env в корне проекта; при запуске вручную читаем тот же .env сами.
|
||||
function envFromFile(file: string, key: string): string {
|
||||
try {
|
||||
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
|
||||
return m ? m[1].trim() : "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION");
|
||||
|
||||
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
|
||||
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Ключи ──────────────────────────────────────────────────────────────────
|
||||
// Личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
|
||||
function loadNodeKey(): crypto.KeyObject {
|
||||
if (!fs.existsSync(NODE_KEY_FILE)) {
|
||||
const { privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
fs.writeFileSync(NODE_KEY_FILE, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
|
||||
console.log("agent: создан ключ ноды " + NODE_KEY_FILE);
|
||||
}
|
||||
return crypto.createPrivateKey(fs.readFileSync(NODE_KEY_FILE));
|
||||
}
|
||||
const nodeKey = loadNodeKey();
|
||||
const nodePubRaw = (() => {
|
||||
const der = crypto.createPublicKey(nodeKey).export({ type: "spki", format: "der" }) as Buffer;
|
||||
return der.subarray(der.length - 32).toString("base64url");
|
||||
})();
|
||||
|
||||
let internalKey: crypto.KeyObject | null = null;
|
||||
try { internalKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE)); }
|
||||
catch { console.warn("agent: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE); }
|
||||
|
||||
// Тот же короткоживущий токен, что awg-ui шлёт своему awg-ctrl.
|
||||
function mintInternalToken(): string {
|
||||
if (!internalKey) throw new Error("no internal key");
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
|
||||
const p = Buffer.from(JSON.stringify({ iss: "awg-agent", iat: t, exp: t + 60 })).toString("base64url");
|
||||
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), internalKey).toString("base64url")}`;
|
||||
}
|
||||
|
||||
// Дублируется в awg-ui/nodes.ts.
|
||||
const authMsg = (nonce: string) => Buffer.from(`awg-node-v1\n${nonce}\n${NODE_ID}`);
|
||||
|
||||
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
|
||||
const METHODS = new Set(["GET", "POST", "PUT", "DELETE"]);
|
||||
// Агент — не открытый прокси: только API самого awg-ctrl.
|
||||
function pathAllowed(p: unknown): p is string {
|
||||
if (typeof p !== "string" || p.length > 2048 || !/^\/[\x21-\x7e]*$/.test(p)) return false;
|
||||
try {
|
||||
const u = new URL(p, "http://x");
|
||||
if (u.origin !== "http://x" || u.pathname.split("/").some(s => s === ".." || s === ".")) return false;
|
||||
return u.pathname === "/health" || u.pathname.startsWith("/api/") || u.pathname.startsWith("/awg/");
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function execute(m: { method?: unknown; path?: unknown; body?: unknown }): Promise<{ status: number; data: unknown }> {
|
||||
const method = typeof m.method === "string" ? m.method.toUpperCase() : "";
|
||||
if (!METHODS.has(method) || !pathAllowed(m.path)) return { status: 400, data: { error: "Запрос не разрешён агентом" } };
|
||||
try {
|
||||
const r = await axios({
|
||||
method, url: CTRL + m.path,
|
||||
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
|
||||
data: m.body, validateStatus: () => true, timeout: 110_000,
|
||||
});
|
||||
return { status: r.status, data: r.data };
|
||||
} catch {
|
||||
return { status: 502, data: { error: "awg-ctrl недоступен" } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Соединение с core ──────────────────────────────────────────────────────
|
||||
function pinOf(sock: TLSSocket): string | null {
|
||||
const raw = sock.getPeerCertificate(true)?.raw;
|
||||
if (!raw) return null;
|
||||
const spki = new crypto.X509Certificate(raw).publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
||||
return crypto.createHash("sha256").update(spki).digest("base64url");
|
||||
}
|
||||
|
||||
// После первого успешного входа секрет больше не нужен — убираем его с диска.
|
||||
function forgetJoinSecret() {
|
||||
joinSecret = "";
|
||||
delete process.env.JOIN_SECRET;
|
||||
if (!CLI_ENV_FILE) return;
|
||||
try {
|
||||
const kept = fs.readFileSync(CLI_ENV_FILE, "utf8").split("\n").filter(l => !l.startsWith("JOIN_SECRET="));
|
||||
fs.writeFileSync(CLI_ENV_FILE, kept.join("\n"));
|
||||
} catch { /* не критично: секрет одноразовый и с истекающим сроком */ }
|
||||
}
|
||||
|
||||
let backoff = 1000;
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
||||
|
||||
function connectOnce(): Promise<{ authFailed: boolean }> {
|
||||
return new Promise(resolve => {
|
||||
// Сертификат самоподписанный — цепочку не проверяем, доверие даёт pin (см. 'upgrade').
|
||||
const ws = new WebSocket(`wss://${CORE_HOST}:${CORE_PORT}/node/v1`, {
|
||||
rejectUnauthorized: false, handshakeTimeout: 10_000, maxPayload: 4 * 1024 * 1024,
|
||||
});
|
||||
let pinned = false, ready = false, deadTimer: NodeJS.Timeout | undefined;
|
||||
const armDead = () => { clearTimeout(deadTimer); deadTimer = setTimeout(() => ws.terminate(), 60_000); };
|
||||
|
||||
// До этого момента ничего не отправляем и не читаем: секрет и подпись уходят только
|
||||
// тому, чей сертификат совпал с pin из join-строки.
|
||||
ws.on("upgrade", res => {
|
||||
const got = pinOf(res.socket as TLSSocket);
|
||||
if (got === CORE_PIN) { pinned = true; return; }
|
||||
console.error("agent: сертификат core не совпал с pin — соединение отклонено");
|
||||
ws.terminate();
|
||||
});
|
||||
|
||||
ws.on("ping", armDead);
|
||||
ws.on("message", async raw => {
|
||||
if (!pinned) return;
|
||||
let m: any;
|
||||
try { m = JSON.parse(raw.toString()); } catch { ws.terminate(); return; }
|
||||
if (m.t === "hello" && typeof m.nonce === "string") {
|
||||
const auth: Record<string, unknown> = {
|
||||
t: "auth", node_id: NODE_ID,
|
||||
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
|
||||
};
|
||||
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
|
||||
if (VERSION) auth.version = VERSION;
|
||||
ws.send(JSON.stringify(auth));
|
||||
} else if (m.t === "ready") {
|
||||
ready = true; backoff = 1000; armDead();
|
||||
if (joinSecret) { forgetJoinSecret(); console.log("agent: нода привязана к core"); }
|
||||
console.log("agent: подключено к core");
|
||||
} else if (m.t === "req" && ready && Number.isInteger(m.id)) {
|
||||
const r = await execute(m);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: "res", id: m.id, ...r }));
|
||||
}
|
||||
});
|
||||
ws.on("error", e => { if (!pinned || !ready) console.error("agent: " + (e as Error).message); });
|
||||
ws.on("close", code => {
|
||||
clearTimeout(deadTimer);
|
||||
if (code === 4003) console.error("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)");
|
||||
resolve({ authFailed: code === 4003 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`agent: нода #${NODE_ID} → wss://${CORE_HOST}:${CORE_PORT}`);
|
||||
for (;;) {
|
||||
const { authFailed } = await connectOnce();
|
||||
const wait = authFailed ? 60_000 : backoff;
|
||||
backoff = Math.min(backoff * 2, 60_000);
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
+652
-13
@@ -1,14 +1,16 @@
|
||||
// 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 fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import { execSync, spawnSync } from "child_process";
|
||||
import { execSync, spawnSync, execFile, spawn } from "child_process";
|
||||
import * as zlib from "zlib";
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import Database from "better-sqlite3";
|
||||
import winston from "winston";
|
||||
import { createShaping } from "./shaping";
|
||||
|
||||
const logsDir = path.join(process.cwd(), "logs");
|
||||
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
||||
@@ -86,6 +88,20 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
// Пиры устройств sen://-подписки. Отдельная таблица, users не трогаем: у этих
|
||||
// пиров нет ни vpn_key, ни имени — приватный ключ живёт только на устройстве,
|
||||
// сюда приходит лишь публичный. owner — непрозрачная метка от awg-ui
|
||||
// («m<masterId>/d<deviceId>»), нужна только для комментария в conf и логов.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS peers (
|
||||
pub_key TEXT PRIMARY KEY,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
psk_key TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
const cfgStmts = {
|
||||
get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"),
|
||||
set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"),
|
||||
@@ -196,13 +212,56 @@ function genOf(p: AwgParams): "2" | "3.1" {
|
||||
return p.HeaderProtectionKey ? "3.1" : "2";
|
||||
}
|
||||
|
||||
// Версия модуля/tools для /health — показывает оператору, что реально работает
|
||||
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
|
||||
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
|
||||
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
|
||||
// это единственные моменты, когда модуль может реально смениться в течение
|
||||
// жизни процесса; из обработчика /health не вызывается — этот роут без
|
||||
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
|
||||
function readAwgVersions(): { module: string; tools: string } {
|
||||
let module = "";
|
||||
try {
|
||||
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
|
||||
} catch {
|
||||
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
|
||||
}
|
||||
let tools = "";
|
||||
try {
|
||||
const out = run("awg --version");
|
||||
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
|
||||
} catch { /* awg-tools не найден */ }
|
||||
return { module, tools };
|
||||
}
|
||||
|
||||
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
|
||||
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
|
||||
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
|
||||
|
||||
// Значения 3.1-параметров для перевода уже работающего 2.0-сервера (POST
|
||||
// /awg/upgrade) — те же, что пишет install.sh (дефолты клиента AmneziaVPN,
|
||||
// protocolConstants.h), в порядке AWG3_KEYS. RandomTrailers/DisableCookies не
|
||||
// включаем — как и инсталлятор. HeaderProtectionKey сюда не входит: он
|
||||
// генерируется на месте (awg genpsk) и фиксируется на весь срок жизни сервера.
|
||||
const AWG31_DEFAULTS: Record<string, string> = {
|
||||
ContentPaddingAddition: "10-100",
|
||||
RekeyAfterTime: "100-120",
|
||||
RekeyTimeout: "3-7",
|
||||
RejectAfterTime: "150-180",
|
||||
KeepaliveTimeout: "5-15",
|
||||
MaxHandshakeAttempts: "15-20",
|
||||
};
|
||||
|
||||
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12
|
||||
// (HEADER_PROTECTION_NONCE_SIZE). Модуль на нарушение отвечает только
|
||||
// «Invalid argument»; причина видна лишь при
|
||||
// `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.
|
||||
const MIN_S_FOR_HEADER_PROTECTION = 12;
|
||||
|
||||
const runtimeConfig = initConfig();
|
||||
const AWG_PARAMS = readAwgParams();
|
||||
const AWG_GEN = genOf(AWG_PARAMS);
|
||||
const AWG_PARAMS = readAwgParams();
|
||||
const AWG_GEN = genOf(AWG_PARAMS);
|
||||
const AWG_VERSIONS = readAwgVersions();
|
||||
const CONFIG = {
|
||||
interface: "awg1",
|
||||
confDir: "/etc/amnezia/amneziawg",
|
||||
@@ -216,6 +275,10 @@ const CONFIG = {
|
||||
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
|
||||
awgParams: AWG_PARAMS,
|
||||
gen: AWG_GEN,
|
||||
// Мутируются в restartAwg() — единственном месте, где модуль может
|
||||
// реально смениться в течение жизни процесса.
|
||||
awgModule: AWG_VERSIONS.module,
|
||||
awgTools: AWG_VERSIONS.tools,
|
||||
};
|
||||
|
||||
interface UserRow {
|
||||
@@ -228,12 +291,28 @@ interface UserRow {
|
||||
vpn_key_prev: string;
|
||||
}
|
||||
|
||||
interface PeerRow {
|
||||
pub_key: string;
|
||||
ip: string;
|
||||
psk_key: string;
|
||||
owner: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const stmts = {
|
||||
peersAll: db.prepare<[], PeerRow>("SELECT * FROM peers ORDER BY created_at, rowid"),
|
||||
peerGet: db.prepare<[string], PeerRow>("SELECT * FROM peers WHERE pub_key = ?"),
|
||||
peerInsert: db.prepare<[string, string, string, string, number]>("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
|
||||
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
|
||||
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
|
||||
peerOwner: db.prepare<[string, string]>("UPDATE peers SET owner = ? WHERE pub_key = ?"),
|
||||
userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"),
|
||||
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
|
||||
all: db.prepare<[], UserRow>("SELECT * FROM users"),
|
||||
insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"),
|
||||
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
|
||||
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"),
|
||||
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users UNION SELECT ip FROM peers"),
|
||||
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
|
||||
// смениться, если исходный ключ не удалось разобрать.
|
||||
reissue: db.prepare<[string, string, string, string, string]>(
|
||||
@@ -497,19 +576,44 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
|
||||
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
|
||||
}
|
||||
|
||||
// Шейпинг (CAKE) и сэмплер тяжёлых устройств. Счётчики читаем асинхронно: цикл
|
||||
// не должен блокировать event loop, как это делают остальные вызовы awg.
|
||||
const shaping = createShaping({
|
||||
iface: CONFIG.interface,
|
||||
getCfg, setCfg, logger,
|
||||
// CAKE видит только внутренний IP-пакет, на проводе к нему добавляются
|
||||
// IPv4 (20) + UDP (8) + заголовок WG (32) = 60 байт и случайный префикс S4.
|
||||
// Формулу сверять iperf3-замером на стенде (SHAPE_OVERHEAD перекрывает её).
|
||||
overhead: () => 60 + (Number(CONFIG.awgParams.S4) || 0),
|
||||
peers: () => new Promise((resolve, reject) => {
|
||||
execFile("awg", ["show", CONFIG.interface, "dump"], { encoding: "utf8", timeout: 10_000 }, (err, out) => {
|
||||
if (err) { reject(err); return; }
|
||||
const rows = out.split("\n").slice(1).map(l => l.split("\t"))
|
||||
.filter(f => f[0] && f[3])
|
||||
.map(f => ({ ip: f[3].split(",")[0].split("/")[0], rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 }));
|
||||
resolve(rows);
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
function rebuildConf() {
|
||||
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
|
||||
const devices = stmts.peersAll.all();
|
||||
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
|
||||
if (!existsSync(confFile)) return;
|
||||
|
||||
const conf = readFileSync(confFile, "utf8");
|
||||
const iface = conf.split(/^\[Peer\]/m)[0].trimEnd();
|
||||
const peers = (users as any[]).map(u =>
|
||||
`\n# ${u.name}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}`
|
||||
// Без записей в peers вывод байт-в-байт прежний.
|
||||
const peers = [
|
||||
...users.map(u => ({ label: u.name, ...u })),
|
||||
...devices.map(d => ({ label: d.owner || "device", ...d })),
|
||||
].map(u =>
|
||||
`\n# ${u.label}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}`
|
||||
).join("\n");
|
||||
|
||||
writeFileSync(confFile, iface + "\n" + peers + "\n");
|
||||
logger.info("conf rebuilt", { peers: users.length });
|
||||
logger.info("conf rebuilt", { peers: users.length + devices.length });
|
||||
}
|
||||
|
||||
const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key";
|
||||
@@ -574,6 +678,17 @@ function ensureInterfaceUp() {
|
||||
if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`);
|
||||
}
|
||||
|
||||
// Параметры обфускации и поколение живут в awg1.conf, а не в процессе:
|
||||
// перечитываем их после любой правки конфига. Иначе awg-ctrl продолжит
|
||||
// собирать vpn:// ключи на старых параметрах — например, на 2.0 уже после
|
||||
// перехода интерфейса на 3.1.
|
||||
function reloadAwgParams(): "2" | "3.1" {
|
||||
CONFIG.awgParams = readAwgParams();
|
||||
CONFIG.gen = genOf(CONFIG.awgParams);
|
||||
CONFIG.keepalive = KEEPALIVE_BY_GEN[CONFIG.gen];
|
||||
return CONFIG.gen;
|
||||
}
|
||||
|
||||
function restartAwg() {
|
||||
logger.info("AWG restart: down");
|
||||
const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
|
||||
@@ -581,8 +696,127 @@ function restartAwg() {
|
||||
logger.info("AWG restart: up");
|
||||
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
||||
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
|
||||
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная
|
||||
// правка awg1.conf) — перечитываем ДО syncPeers: keepalive из CONFIG уходит
|
||||
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
|
||||
const gen = reloadAwgParams();
|
||||
syncPeers();
|
||||
logger.info("AWG restart: done");
|
||||
void shaping.apply(); // tc-правила жили на интерфейсе, который мы только что пересоздали
|
||||
const versions = readAwgVersions();
|
||||
CONFIG.awgModule = versions.module;
|
||||
CONFIG.awgTools = versions.tools;
|
||||
logger.info("AWG restart: done", { ...versions, gen });
|
||||
}
|
||||
|
||||
// ─────────────────────────── переход 2.0 → 3.1 ───────────────────────────
|
||||
// 3.1 — основное поколение (install.sh ставит его по умолчанию). Сервера,
|
||||
// установленные раньше, переводятся отсюда, без переустановки: панель дёргает
|
||||
// POST /awg/upgrade.
|
||||
|
||||
// Header protection использует библиотечный chacha-API (chacha_init/
|
||||
// chacha20_crypt), которого нет до ядра 5.5 — там модуль 3.x просто не
|
||||
// соберётся (upstream issue #210).
|
||||
function kernelSupportsHeaderProtection(): boolean {
|
||||
const [maj, min] = os.release().split(".").map(n => Number.parseInt(n, 10));
|
||||
if (!Number.isFinite(maj) || !Number.isFinite(min)) return false;
|
||||
return maj > 5 || (maj === 5 && min >= 5);
|
||||
}
|
||||
|
||||
// Пред-проверка перед тем, как трогать конфиг: решает ЗАГРУЖЕННЫЙ модуль
|
||||
// (readAwgVersions читает /sys/module/amneziawg/version), а не тот, что лежит
|
||||
// на диске после apt — обслуживает интерфейс именно загруженный.
|
||||
function checkGen31Support(): string | null {
|
||||
const { module } = readAwgVersions();
|
||||
if (!module.startsWith("3."))
|
||||
return `В ядре загружен модуль amneziawg ${module || "неизвестной версии"} — для 3.1 нужна 3.x. `
|
||||
+ "Обнови пакет (apt) и перезагрузи сервер, затем повтори переход.";
|
||||
if (!kernelSupportsHeaderProtection())
|
||||
return `Ядро ${os.release()} старше 5.5 — header protection в нём не работает (upstream issue #210).`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const AWG3_KEY_LINE = new RegExp(`^\\s*(${AWG3_KEYS.join("|")})\\s*=`);
|
||||
|
||||
// Готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
|
||||
// править руками), поднимает S1–S4 до допустимых при header protection и
|
||||
// дописывает 3.x-блок в конец [Interface]. [Peer]-блоки не трогаются —
|
||||
// личности пиров переход не меняет.
|
||||
function confWithGen31(conf: string, headerKey: string): { conf: string; bumpedS: string[] } {
|
||||
const peerAt = conf.search(/^\[Peer\]/m);
|
||||
let rest = peerAt >= 0 ? conf.slice(peerAt) : "";
|
||||
const head = (peerAt >= 0 ? conf.slice(0, peerAt) : conf)
|
||||
.split("\n")
|
||||
.filter(l => !AWG3_KEY_LINE.test(l));
|
||||
|
||||
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому
|
||||
// [Peer] (их пишет rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь
|
||||
// [Interface], а не между комментарием и его пиром.
|
||||
const tail: string[] = [];
|
||||
while (head.length && (head[head.length - 1].trim() === "" || head[head.length - 1].trim().startsWith("#")))
|
||||
tail.unshift(head.pop()!);
|
||||
rest = tail.join("\n").trim() ? `${tail.join("\n").trim()}\n${rest}` : rest;
|
||||
|
||||
let iface = head.join("\n");
|
||||
|
||||
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет.
|
||||
// Правка безопасна ровно потому, что следом идёт перевыпуск ключей: обе
|
||||
// стороны получают новые значения одновременно.
|
||||
const bumpedS: string[] = [];
|
||||
for (const k of ["S1", "S2", "S3", "S4"] as const) {
|
||||
const re = new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)\\s*$`, "m");
|
||||
const cur = Number(iface.match(re)?.[1]);
|
||||
if (Number.isFinite(cur) && cur >= MIN_S_FOR_HEADER_PROTECTION) continue;
|
||||
const val = Math.max(DEFAULT_AWG_PARAMS[k], MIN_S_FOR_HEADER_PROTECTION);
|
||||
iface = re.test(iface)
|
||||
? iface.replace(re, `${k} = ${val}`)
|
||||
: `${iface.trimEnd()}\n${k} = ${val}`;
|
||||
bumpedS.push(`${k}: ${Number.isFinite(cur) ? cur : "нет"} → ${val}`);
|
||||
}
|
||||
|
||||
const awg3 = [
|
||||
`HeaderProtectionKey = ${headerKey}`,
|
||||
...Object.entries(AWG31_DEFAULTS).map(([k, v]) => `${k} = ${v}`),
|
||||
].join("\n");
|
||||
|
||||
return { conf: `${iface.trimEnd()}\n${awg3}\n${rest ? `\n${rest}` : ""}`, bumpedS };
|
||||
}
|
||||
|
||||
interface UpgradeResult { gen: "2" | "3.1"; backup: string; bumpedS: string[]; reissue: ReissueResult }
|
||||
|
||||
// Перевод интерфейса на 3.1. Единственная настоящая проверка, что ядро приняло
|
||||
// параметры, — подъём интерфейса, поэтому шаги такие: бэкап конфига → запись
|
||||
// 3.1 → restartAwg() (он же перечитает параметры) → перевыпуск ключей. Если
|
||||
// ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
|
||||
// операция либо применяется целиком, либо не оставляет следов.
|
||||
function upgradeToGen31(): UpgradeResult {
|
||||
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
|
||||
const before = readFileSync(confFile, "utf8");
|
||||
const backup = `${confFile}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
writeFileSync(backup, before, { mode: 0o600 });
|
||||
|
||||
const { conf, bumpedS } = confWithGen31(before, run("awg genpsk"));
|
||||
writeFileSync(confFile, conf, { mode: 0o600 });
|
||||
logger.info("upgrade: conf rewritten for 3.1", { backup, bumpedS });
|
||||
|
||||
try {
|
||||
restartAwg();
|
||||
if (CONFIG.gen !== "3.1") throw new Error("интерфейс поднялся, но конфиг всё ещё 2.0");
|
||||
} catch (e) {
|
||||
logger.error("upgrade: kernel rejected 3.1, rolling back", { error: e });
|
||||
writeFileSync(confFile, before, { mode: 0o600 });
|
||||
try { restartAwg(); } catch (e2) { logger.error("upgrade: rollback restart failed", { error: e2 }); }
|
||||
throw new Error(
|
||||
`Ядро не приняло конфиг 3.1 — вернули прежний (копия: ${backup}). `
|
||||
+ 'Причина видна в dmesg после `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.',
|
||||
);
|
||||
}
|
||||
|
||||
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся,
|
||||
// поэтому перевыпускаем сразу — сервер не должен оставаться в состоянии
|
||||
// «3.1 поднят, у всех нерабочие ключи». Личности пиров сохраняются.
|
||||
const reissue = reissueAll();
|
||||
logger.info("upgrade: done", { gen: CONFIG.gen, reissued: reissue.reissued, bumpedS });
|
||||
return { gen: CONFIG.gen, backup, bumpedS, reissue };
|
||||
}
|
||||
|
||||
function startInterface() {
|
||||
@@ -591,6 +825,7 @@ function startInterface() {
|
||||
const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
||||
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
|
||||
syncPeers();
|
||||
void shaping.apply();
|
||||
return getInterfaceStatus();
|
||||
}
|
||||
|
||||
@@ -637,12 +872,30 @@ function addUser(username: string): UserRow {
|
||||
};
|
||||
}
|
||||
|
||||
// Снять пира с интерфейса и убедиться, что его там нет. Раньше результат `awg set … remove`
|
||||
// не проверялся: строку из базы удаляли, а пир в ядре оставался живым, и про него уже никто не
|
||||
// знал. Теперь порядок такой: сначала ядро, потом база — при отказе строка остаётся, и
|
||||
// повторная попытка (надгробие панели, следующий sync) найдёт пира.
|
||||
function kernelPeers(): Set<string> | null {
|
||||
const r = spawnSync("awg", ["show", CONFIG.interface, "peers"]);
|
||||
return r.status === 0 ? new Set(r.stdout.toString().split("\n").map(l => l.trim()).filter(Boolean)) : null;
|
||||
}
|
||||
|
||||
function dropFromKernel(pub: string): boolean {
|
||||
const r = spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
|
||||
if (r.status === 0) return true;
|
||||
const left = kernelPeers();
|
||||
if (left && !left.has(pub)) return true; // ответ странный, но пира уже нет
|
||||
logger.error("peer remove from kernel failed", { pub, stderr: r.stderr?.toString() });
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeUser(username: string) {
|
||||
const user = stmts.get.get(username);
|
||||
if (!user) throw new Error("Пользователь не найден");
|
||||
|
||||
if (!dropFromKernel(user.pub_key)) throw new Error("Не удалось снять пира с интерфейса");
|
||||
stmts.delete.run(username);
|
||||
spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]);
|
||||
rebuildConf();
|
||||
|
||||
for (const ext of [".conf", ".key"]) {
|
||||
@@ -652,8 +905,136 @@ function removeUser(username: string) {
|
||||
logger.info("user removed", { name: username });
|
||||
}
|
||||
|
||||
// ── Пиры устройств (sen://) ────────────────────────────────────────────────
|
||||
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим
|
||||
// публичным ключом». Приватного ключа устройства здесь нет и быть не может.
|
||||
class HttpError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
// Публичный ключ WireGuard: base64 от 32 байт (44 символа, последний «=»).
|
||||
function checkPubKey(k: unknown): string {
|
||||
if (typeof k !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(k) || Buffer.from(k, "base64").length !== 32)
|
||||
throw new HttpError(400, "Неверный публичный ключ");
|
||||
return k;
|
||||
}
|
||||
|
||||
// В пути base64url: «/» и «+» обычного base64 в URL ломают маршрут.
|
||||
function pubKeyFromParam(p: string): string {
|
||||
return checkPubKey(Buffer.from(p, "base64url").toString("base64"));
|
||||
}
|
||||
|
||||
function assertPubKeyFree(pub: string) {
|
||||
if (stmts.peerGet.get(pub) || stmts.userPubTaken.get(pub)!.n > 0 || pub === getServerPublicKey())
|
||||
throw new HttpError(409, "Публичный ключ уже используется");
|
||||
}
|
||||
|
||||
function addPeer(pubKey: string, owner: string): { ip: string; psk_key: string } {
|
||||
checkPubKey(pubKey);
|
||||
const psk = run("awg genpsk");
|
||||
const ip = db.transaction(() => {
|
||||
assertPubKeyFree(pubKey);
|
||||
const ip = nextIp();
|
||||
stmts.peerInsert.run(pubKey, ip, psk, owner, Math.floor(Date.now() / 1000));
|
||||
return ip;
|
||||
})();
|
||||
|
||||
const r = setPeer(pubKey, psk, ip);
|
||||
if (r.status !== 0) {
|
||||
stmts.peerDelete.run(pubKey);
|
||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
}
|
||||
rebuildConf();
|
||||
logger.info("peer added", { owner, ip });
|
||||
return { ip, psk_key: psk };
|
||||
}
|
||||
|
||||
// Смена ключа устройства: IP и PSK остаются, меняется только pub_key.
|
||||
function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: string } {
|
||||
checkPubKey(newPub);
|
||||
const old = stmts.peerGet.get(oldPub);
|
||||
if (!old) throw new HttpError(404, "Пир не найден");
|
||||
db.transaction(() => { assertPubKeyFree(newPub); stmts.peerRekey.run(newPub, oldPub); })();
|
||||
|
||||
const r = setPeer(newPub, old.psk_key, old.ip);
|
||||
if (r.status !== 0) {
|
||||
stmts.peerRekey.run(oldPub, newPub);
|
||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
}
|
||||
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа не откатываем:
|
||||
// новый уже стоит, а клиент к этому моменту получил бы ошибку.
|
||||
if (!dropFromKernel(oldPub)) logger.error("peer rekey: старый ключ остался в ядре", { owner: old.owner });
|
||||
rebuildConf();
|
||||
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
|
||||
return { ip: old.ip, psk_key: old.psk_key };
|
||||
}
|
||||
|
||||
function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
|
||||
const peer = stmts.peerGet.get(pub);
|
||||
if (!peer) throw new HttpError(404, "Пир не найден");
|
||||
const psk = run("awg genpsk");
|
||||
const r = setPeer(pub, psk, peer.ip);
|
||||
if (r.status !== 0) throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
stmts.peerPsk.run(psk, pub);
|
||||
rebuildConf();
|
||||
logger.info("peer psk rotated", { owner: peer.owner });
|
||||
return { ip: peer.ip, psk_key: psk };
|
||||
}
|
||||
|
||||
function removePeer(pub: string) {
|
||||
const peer = stmts.peerGet.get(pub);
|
||||
if (!peer) throw new HttpError(404, "Пир не найден");
|
||||
if (!dropFromKernel(pub)) throw new Error("Не удалось снять пира с интерфейса");
|
||||
stmts.peerDelete.run(pub);
|
||||
rebuildConf();
|
||||
logger.info("peer removed", { owner: peer.owner });
|
||||
}
|
||||
|
||||
// Сведение пиров одной панели: панель присылает полный список ключей, которые ей нужны
|
||||
// на этом сервере, — всё остальное из её области («<panel>/…») снимается. Удаляем только
|
||||
// по такому явному списку, никогда по времени или неактивности: панель не на связи —
|
||||
// ничего не происходит. Пиры без области (owner «m1/d2», выданы до областей) панель
|
||||
// забирает себе, только если их ключ есть в её списке; остальные не трогаем — чьи они,
|
||||
// уже не разобрать.
|
||||
const PANEL_ID = /^[a-z0-9]{8,16}$/;
|
||||
const LEGACY_OWNER = /^m\d+\/d\d+$/;
|
||||
const SYNC_MAX = 10_000;
|
||||
|
||||
function syncOwnedPeers(panel: string, keep: string[]): {
|
||||
present: string[]; removed: number; legacy: { pub_key: string; owner: string; ip: string }[];
|
||||
} {
|
||||
const want = new Set(keep);
|
||||
const prefix = panel + "/";
|
||||
const removed: PeerRow[] = [];
|
||||
let claimed = 0;
|
||||
for (const p of stmts.peersAll.all()) {
|
||||
if (p.owner.startsWith(prefix)) {
|
||||
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
|
||||
if (!want.has(p.pub_key) && dropFromKernel(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
|
||||
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
|
||||
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
|
||||
claimed++;
|
||||
}
|
||||
}
|
||||
if (removed.length || claimed) rebuildConf();
|
||||
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
|
||||
|
||||
const all = stmts.peersAll.all();
|
||||
return {
|
||||
present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key),
|
||||
removed: removed.length,
|
||||
legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })),
|
||||
};
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1kb" }));
|
||||
// Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 1kb.
|
||||
const SYNC_PATH = "/api/peers/sync";
|
||||
const jsonSmall = express.json({ limit: "1kb" });
|
||||
const jsonSync = express.json({ limit: "512kb" });
|
||||
// /api/audit/clean тоже несёт до SYNC_MAX ключей — под 1kb он принимал бы около двадцати.
|
||||
const BIG_BODY_PATHS = new Set([SYNC_PATH, "/api/audit/clean"]);
|
||||
app.use((req, res, next) => (BIG_BODY_PATHS.has(req.path) ? jsonSync : jsonSmall)(req, res, next));
|
||||
|
||||
function verifyInternalToken(token: string): boolean {
|
||||
try {
|
||||
@@ -686,7 +1067,10 @@ function validateName(req: Request, res: Response, next: NextFunction) {
|
||||
function handler(fn: (req: Request, res: Response) => void | Promise<void>) {
|
||||
return async (req: Request, res: Response) => {
|
||||
try { await fn(req, res); }
|
||||
catch (e) { logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" }); }
|
||||
catch (e) {
|
||||
if (e instanceof HttpError) { res.status(e.status).json({ error: e.message }); return; }
|
||||
logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -697,7 +1081,7 @@ app.get("/health", (_req, res) => {
|
||||
server: CONFIG.serverName,
|
||||
ip: CONFIG.serverIp,
|
||||
gen: CONFIG.gen,
|
||||
awg: { status: up ? "ok" : "down", peers },
|
||||
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -733,6 +1117,8 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
|
||||
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
|
||||
rx: peers[u.pub_key]?.rx ?? 0,
|
||||
tx: peers[u.pub_key]?.tx ?? 0,
|
||||
// «normal» | «background»: фоновый — пока канал занят, а устройство давно качает больше своей доли
|
||||
priority: shaping.priorityOf(u.ip),
|
||||
})),
|
||||
});
|
||||
}));
|
||||
@@ -754,11 +1140,260 @@ app.delete("/api/users/:name", auth, validateName, handler((req, res) => {
|
||||
res.json({ success: true, name: req.params.name });
|
||||
}));
|
||||
|
||||
app.post("/api/peers", auth, handler((req, res) => {
|
||||
const { pub_key, owner } = (req.body ?? {}) as { pub_key?: string; owner?: string };
|
||||
if (typeof owner !== "string" || !/^[\w/.-]{0,64}$/.test(owner)) throw new HttpError(400, "Неверный owner");
|
||||
res.status(201).json(addPeer(checkPubKey(pub_key), owner));
|
||||
}));
|
||||
|
||||
app.get("/api/peers", auth, handler((_req, res) => {
|
||||
const stats = getPeersData();
|
||||
res.json({
|
||||
peers: stmts.peersAll.all().map(p => ({
|
||||
pub_key: p.pub_key, ip: p.ip, psk_key: p.psk_key, owner: p.owner,
|
||||
online: stats[p.pub_key]?.online ?? false,
|
||||
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
|
||||
rx: stats[p.pub_key]?.rx ?? 0,
|
||||
tx: stats[p.pub_key]?.tx ?? 0,
|
||||
priority: shaping.priorityOf(p.ip),
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять их
|
||||
// после сбоя `awg set … remove` некому. Только чтение — панель (core) сама решает, что делать.
|
||||
app.get("/api/audit", auth, handler((_req, res) => {
|
||||
const r = spawnSync("awg", ["show", CONFIG.interface, "dump"]);
|
||||
if (r.status !== 0) throw new HttpError(503, "awg show не ответил");
|
||||
const known = new Set<string>([
|
||||
...(db.prepare("SELECT pub_key FROM users").all() as { pub_key: string }[]).map(u => u.pub_key),
|
||||
...stmts.peersAll.all().map(p => p.pub_key),
|
||||
]);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ghosts = r.stdout.toString().split("\n").slice(1).map(l => l.split("\t")).filter(f => f[0] && !known.has(f[0])).map(f => {
|
||||
const lastHandshake = Number(f[4]) || 0;
|
||||
return { pub_key: f[0], lastHandshake, online: lastHandshake > 0 && now - lastHandshake < 180, rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 };
|
||||
});
|
||||
res.json({ ghosts });
|
||||
}));
|
||||
|
||||
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется: пир, который
|
||||
// успел появиться в users/peers, не трогаем. Строк в базе у «призраков» нет — снимается только ядро.
|
||||
app.post("/api/audit/clean", auth, handler((req, res) => {
|
||||
const { pub_keys } = (req.body ?? {}) as { pub_keys?: unknown };
|
||||
if (!Array.isArray(pub_keys) || pub_keys.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
|
||||
const removed: string[] = [], skipped: string[] = [];
|
||||
for (const k of pub_keys.map(checkPubKey)) {
|
||||
const known = stmts.peerGet.get(k) || (db.prepare("SELECT 1 FROM users WHERE pub_key = ?").get(k));
|
||||
if (known || k === getServerPublicKey() || !kernelPeers()?.has(k)) { skipped.push(k); continue; }
|
||||
(dropFromKernel(k) ? removed : skipped).push(k);
|
||||
}
|
||||
if (removed.length) logger.warn("ghost peers removed from kernel", { count: removed.length, removed });
|
||||
res.json({ removed, skipped });
|
||||
}));
|
||||
|
||||
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
|
||||
app.put(SYNC_PATH, auth, handler((req, res) => {
|
||||
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
|
||||
if (typeof owner !== "string" || !PANEL_ID.test(owner)) throw new HttpError(400, "Неверный owner");
|
||||
if (!Array.isArray(keep) || keep.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
|
||||
res.json(syncOwnedPeers(owner, keep.map(checkPubKey)));
|
||||
}));
|
||||
|
||||
app.put("/api/peers/:pub", auth, handler((req, res) => {
|
||||
const { pub_key } = (req.body ?? {}) as { pub_key?: string };
|
||||
res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key)));
|
||||
}));
|
||||
|
||||
app.post("/api/peers/:pub/psk", auth, handler((req, res) => {
|
||||
res.json(rotatePeerPsk(pubKeyFromParam(req.params.pub)));
|
||||
}));
|
||||
|
||||
app.delete("/api/peers/:pub", auth, handler((req, res) => {
|
||||
removePeer(pubKeyFromParam(req.params.pub));
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
|
||||
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
|
||||
app.get("/api/profile", auth, handler((_req, res) => {
|
||||
const awg: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(CONFIG.awgParams)) if (String(v) !== "") awg[k] = String(v);
|
||||
res.json({
|
||||
name: CONFIG.serverName,
|
||||
endpoint: `${CONFIG.serverIp}:${CONFIG.serverPort}`,
|
||||
server_pub: getServerPublicKey(),
|
||||
gen: CONFIG.gen,
|
||||
dns: [CONFIG.dns1, CONFIG.dns2],
|
||||
keepalive: CONFIG.keepalive,
|
||||
mtu: CONFIG.mtu,
|
||||
awg,
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/awg/restart", auth, handler((_req, res) => {
|
||||
restartAwg();
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей.
|
||||
// Ошибку отдаём текстом: панель показывает её оператору, а причины отказа
|
||||
// (старый модуль, старое ядро, отказ ядра на подъёме) требуют разных действий.
|
||||
app.post("/awg/upgrade", auth, handler((_req, res) => {
|
||||
if (CONFIG.gen === "3.1") {
|
||||
res.status(409).json({ error: "Сервер уже работает на AmneziaWG 3.1" }); return;
|
||||
}
|
||||
const blocker = checkGen31Support();
|
||||
if (blocker) { res.status(409).json({ error: blocker }); return; }
|
||||
try {
|
||||
res.json(upgradeToGen31());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : "Не удалось перейти на 3.1" });
|
||||
}
|
||||
}));
|
||||
|
||||
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не пропустит
|
||||
// (и без PATCH: агент отвечает на него 400).
|
||||
app.get("/api/shaping", auth, handler(async (_req, res) => {
|
||||
res.json(await shaping.status());
|
||||
}));
|
||||
|
||||
app.put("/api/shaping", auth, handler(async (req, res) => {
|
||||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||
const mbit = (v: unknown, what: string): number => {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 100_000) throw new HttpError(400, `${what}: целое число Мбит/с от 1 до 100000`);
|
||||
return n;
|
||||
};
|
||||
if (typeof b.enabled !== "boolean" || typeof b.auto !== "boolean")
|
||||
throw new HttpError(400, "enabled и auto должны быть true/false");
|
||||
const downMbit = mbit(b.downMbit ?? 0, "Загрузка");
|
||||
const upMbit = mbit(b.upMbit ?? 0, "Отдача");
|
||||
if (b.enabled && (downMbit < 1 || upMbit < 1))
|
||||
throw new HttpError(400, "Для включения укажите ширину канала в обе стороны");
|
||||
try {
|
||||
await shaping.update({ enabled: b.enabled, auto: b.auto, downMbit, upMbit });
|
||||
} catch (e) {
|
||||
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
|
||||
throw new HttpError(500, e instanceof Error ? e.message : "Не удалось применить шейпинг");
|
||||
}
|
||||
res.json(await shaping.status());
|
||||
}));
|
||||
|
||||
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
|
||||
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
|
||||
// несколько скрейперов не должны гонять `awg show` чаще.
|
||||
const METRICS_TTL_MS = 5_000;
|
||||
let metricsCache: { at: number; data: Promise<unknown> } | null = null;
|
||||
|
||||
function linkCounter(name: string): number {
|
||||
try { return Number(readFileSync(`/sys/class/net/${CONFIG.interface}/statistics/${name}`, "utf8")) || 0; }
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
// Нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у node_exporter): долю считает
|
||||
// Prometheus через rate(), здесь мгновенного «процента» нет. Память — MemAvailable, а не MemFree: кэш ядра свободен.
|
||||
const CPU_MODES = ["user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"] as const;
|
||||
function readHost() {
|
||||
try {
|
||||
const first = readFileSync("/proc/stat", "utf8").split("\n")[0].trim().split(/\s+/).slice(1).map(Number);
|
||||
const cpu: Record<string, number> = {};
|
||||
CPU_MODES.forEach((m, i) => { cpu[m] = (first[i] || 0) / 100; }); // USER_HZ = 100 на всех обычных ядрах
|
||||
const mem = readFileSync("/proc/meminfo", "utf8");
|
||||
const kb = (k: string) => Number(mem.match(new RegExp(`^${k}:\\s+(\\d+) kB`, "m"))?.[1] ?? 0) * 1024;
|
||||
return {
|
||||
cpu, cores: os.cpus().length, load: os.loadavg(),
|
||||
mem: { total: kb("MemTotal"), available: kb("MemAvailable") },
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function collectMetrics() {
|
||||
const st = getInterfaceStatus();
|
||||
const stats = st.up ? getPeersData() : {};
|
||||
const list: { kind: "user" | "device"; name: string; ip: string; online: boolean; rx: number; tx: number; priority: string }[] = [
|
||||
...(db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]).map(u => ({
|
||||
kind: "user" as const, name: u.name, ip: u.ip,
|
||||
online: stats[u.pub_key]?.online ?? false, rx: stats[u.pub_key]?.rx ?? 0, tx: stats[u.pub_key]?.tx ?? 0,
|
||||
priority: shaping.priorityOf(u.ip),
|
||||
})),
|
||||
...stmts.peersAll.all().map(p => ({
|
||||
kind: "device" as const, name: "", ip: p.ip,
|
||||
online: stats[p.pub_key]?.online ?? false, rx: stats[p.pub_key]?.rx ?? 0, tx: stats[p.pub_key]?.tx ?? 0,
|
||||
priority: shaping.priorityOf(p.ip),
|
||||
})),
|
||||
];
|
||||
const all = Object.values(stats);
|
||||
return {
|
||||
up: st.up,
|
||||
gen: CONFIG.gen,
|
||||
peers: { total: st.peers, online: all.filter(p => p.online).length },
|
||||
// С точки зрения сервера: rx — принято от клиентов (их отдача), tx — отправлено клиентам (их загрузка).
|
||||
rx_bytes: all.reduce((a, p) => a + p.rx, 0),
|
||||
tx_bytes: all.reduce((a, p) => a + p.tx, 0),
|
||||
link: {
|
||||
rx_bytes: linkCounter("rx_bytes"), tx_bytes: linkCounter("tx_bytes"),
|
||||
rx_packets: linkCounter("rx_packets"), tx_packets: linkCounter("tx_packets"),
|
||||
rx_dropped: linkCounter("rx_dropped"), tx_dropped: linkCounter("tx_dropped"),
|
||||
rx_errors: linkCounter("rx_errors"), tx_errors: linkCounter("tx_errors"),
|
||||
},
|
||||
shaping: await shaping.status(),
|
||||
host: readHost(),
|
||||
list,
|
||||
};
|
||||
}
|
||||
|
||||
app.get("/api/metrics", auth, handler(async (_req, res) => {
|
||||
if (!metricsCache || Date.now() - metricsCache.at > METRICS_TTL_MS) {
|
||||
const data = collectMetrics();
|
||||
metricsCache = { at: Date.now(), data };
|
||||
data.catch(() => { metricsCache = null; }); // ошибку не кэшируем
|
||||
}
|
||||
res.json(await metricsCache.data);
|
||||
}));
|
||||
|
||||
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие одно из двух и без
|
||||
// параметров: awg-ctrl запускает ту же команду, что оператор набрал бы руками (`awg-ctrl monitor install|remove`,
|
||||
// root), так что код один. Панель сама пакетов не ставит и не удаляет — она только просит об этом здесь.
|
||||
const MONITOR_CLI = process.env.AWG_CLI ?? "/usr/local/bin/awg-ctrl";
|
||||
const MONITOR_LOG = process.env.MONITOR_LOG ?? path.resolve(process.cwd(), "..", "logs", "monitor-job.log");
|
||||
type MonitorAction = "install" | "remove";
|
||||
let monitorJob: { action: MonitorAction; startedAt: number; exit: number | null } | null = null;
|
||||
|
||||
const logTail = (): string => {
|
||||
try {
|
||||
const t = readFileSync(MONITOR_LOG, "utf8").slice(-4000);
|
||||
// apt рисует прогресс возвратом каретки и цветами — в панели это мусор
|
||||
return t.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "\n");
|
||||
} catch { return ""; }
|
||||
};
|
||||
|
||||
function startMonitorJob(action: MonitorAction, res: Response) {
|
||||
if (monitorJob && monitorJob.exit === null) { res.status(409).json({ error: "Операция уже идёт" }); return; }
|
||||
if (!existsSync(MONITOR_CLI)) throw new HttpError(501, `CLI не найден (${MONITOR_CLI}): выполните на сервере awg-ctrl monitor ${action}`);
|
||||
mkdirSync(path.dirname(MONITOR_LOG), { recursive: true });
|
||||
const fd = openSync(MONITOR_LOG, "w");
|
||||
const job = { action, startedAt: Date.now(), exit: null as number | null };
|
||||
monitorJob = job;
|
||||
const done = (code: number) => { job.exit = code; try { closeSync(fd); } catch { /* уже закрыт */ } };
|
||||
const child = spawn(MONITOR_CLI, ["monitor", action], { stdio: ["ignore", fd, fd] });
|
||||
child.on("error", e => { logger.error("monitor: не запустился", { action, error: e.message }); done(127); });
|
||||
child.on("exit", code => { logger.info("monitor: завершён", { action, code }); done(code ?? 1); });
|
||||
res.status(202).json({ running: true, action });
|
||||
}
|
||||
|
||||
app.post("/api/monitor/install", auth, handler((_req, res) => { startMonitorJob("install", res); }));
|
||||
app.post("/api/monitor/remove", auth, handler((_req, res) => { startMonitorJob("remove", res); }));
|
||||
|
||||
app.get("/api/monitor/job", auth, handler((_req, res) => {
|
||||
res.json({
|
||||
action: monitorJob?.action ?? null,
|
||||
running: !!monitorJob && monitorJob.exit === null,
|
||||
exit: monitorJob?.exit ?? null,
|
||||
log: monitorJob ? logTail() : "",
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/awg/status", auth, handler((_req, res) => {
|
||||
res.json(getInterfaceStatus());
|
||||
}));
|
||||
@@ -772,3 +1407,7 @@ app.listen(SERVER.port, "127.0.0.1", () => {
|
||||
});
|
||||
ensureInterfaceUp();
|
||||
syncPeers();
|
||||
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
|
||||
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
|
||||
void shaping.apply();
|
||||
shaping.start();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.1.4+1",
|
||||
"version": "0.5.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
|
||||
//
|
||||
// Схема:
|
||||
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
|
||||
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
|
||||
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
|
||||
//
|
||||
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
|
||||
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому apply() зовётся
|
||||
// на старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
|
||||
//
|
||||
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по
|
||||
// времени, ни по неактивности (правило проекта: удаление только по явному списку
|
||||
// от панели).
|
||||
import { execFile, spawn } from "child_process";
|
||||
import type { Logger } from "winston";
|
||||
|
||||
export interface ShapingSettings {
|
||||
enabled: boolean;
|
||||
downMbit: number;
|
||||
upMbit: number;
|
||||
auto: boolean;
|
||||
}
|
||||
|
||||
export interface PeerCounters {
|
||||
ip: string;
|
||||
rx: number; // принято от клиента (его отдача)
|
||||
tx: number; // отправлено клиенту (его загрузка)
|
||||
}
|
||||
|
||||
export interface ShapingDeps {
|
||||
iface: string;
|
||||
getCfg: (key: string, fallback: string) => string;
|
||||
setCfg: (key: string, value: string) => void;
|
||||
logger: Pick<Logger, "info" | "warn" | "error">;
|
||||
// Байт на пакет, которые туннель добавляет на проводе (CAKE видит только внутренний IP-пакет).
|
||||
overhead: () => number;
|
||||
peers: () => Promise<PeerCounters[]>;
|
||||
}
|
||||
|
||||
export interface QdiscStats { packets: number; bytes: number; drops: number; backlog: number }
|
||||
|
||||
export interface ShapingStatus {
|
||||
settings: ShapingSettings;
|
||||
active: boolean; // CAKE реально стоит на интерфейсе
|
||||
congested: boolean;
|
||||
heavy: string[]; // IP устройств в bulk-классе
|
||||
devices: number; // активных устройств в окне
|
||||
rateDown: number; // Мбит/с за последний тик
|
||||
rateUp: number;
|
||||
down?: QdiscStats;
|
||||
up?: QdiscStats;
|
||||
}
|
||||
|
||||
// Окна и пороги. Переопределение через env — только для тестов на стенде.
|
||||
const num = (v: string | undefined, d: number) => (v && Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
|
||||
const SAMPLE_S = num(process.env.SHAPE_SAMPLE_S, 10);
|
||||
const WINDOW_S = num(process.env.SHAPE_WINDOW_S, 600);
|
||||
const CONGEST_ON = 0.85; // доля ширины, выше которой канал считается перегруженным
|
||||
const CONGEST_OFF = 0.70; // и ниже которой перегрузка снимается (гистерезис)
|
||||
const ACTIVE_MBIT = 0.1; // меньше — устройство не считается активным
|
||||
const HEAVY_MULT = 1.5; // тяжёлое: ≥ 1.5 честных долей…
|
||||
const HEAVY_MIN_FRAC = 0.05; // …и не меньше 5% ширины канала
|
||||
const CHECK_EVERY = 3; // страховочная проверка qdisc — раз в N тиков
|
||||
|
||||
const IFB = "ifb-awg";
|
||||
const NFT_TABLE = "awg_shape";
|
||||
|
||||
function exec(cmd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { encoding: "utf8", timeout: 15_000 }, (err, stdout, stderr) => {
|
||||
if (err) reject(new Error(`${cmd} ${args.join(" ")}: ${(stderr || err.message).toString().trim()}`));
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function execStdin(cmd: string, args: string[], input: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
|
||||
let err = "";
|
||||
p.stderr.on("data", d => { err += d; });
|
||||
p.stdin.on("error", () => undefined); // процесс умер раньше, чем дочитал stdin — причина придёт в close
|
||||
p.on("error", reject);
|
||||
p.on("close", code => code === 0 ? resolve() : reject(new Error(`${cmd}: ${err.trim() || `exit ${code}`}`)));
|
||||
p.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
interface Tick { dt: number; bytes: number }
|
||||
|
||||
export function createShaping(deps: ShapingDeps) {
|
||||
const { iface, logger } = deps;
|
||||
|
||||
// ───────────── настройки ─────────────
|
||||
function settings(): ShapingSettings {
|
||||
return {
|
||||
enabled: deps.getCfg("shapeEnabled", "0") === "1",
|
||||
downMbit: Number(deps.getCfg("shapeDownMbit", "0")) || 0,
|
||||
upMbit: Number(deps.getCfg("shapeUpMbit", "0")) || 0,
|
||||
auto: deps.getCfg("shapeAuto", "1") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
function save(s: ShapingSettings) {
|
||||
deps.setCfg("shapeEnabled", s.enabled ? "1" : "0");
|
||||
deps.setCfg("shapeDownMbit", String(s.downMbit));
|
||||
deps.setCfg("shapeUpMbit", String(s.upMbit));
|
||||
deps.setCfg("shapeAuto", s.auto ? "1" : "0");
|
||||
}
|
||||
|
||||
// ───────────── применение ─────────────
|
||||
// Все изменения tc/nft идут строго по одному: два параллельных apply() дали бы
|
||||
// полусобранные qdisc.
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
const r = chain.then(fn, fn);
|
||||
chain = r.catch(() => undefined);
|
||||
return r;
|
||||
};
|
||||
|
||||
let lastError = "";
|
||||
const noteError = (e: unknown) => {
|
||||
const m = e instanceof Error ? e.message : String(e);
|
||||
if (m !== lastError) logger.warn("shaping: " + m); // одинаковую ошибку в лог не спамим
|
||||
lastError = m;
|
||||
};
|
||||
|
||||
const cakeOverhead = () => {
|
||||
const o = Number(process.env.SHAPE_OVERHEAD);
|
||||
return Number.isFinite(o) && o >= 0 && process.env.SHAPE_OVERHEAD ? o : deps.overhead();
|
||||
};
|
||||
|
||||
async function nftApply(heavy: string[]) {
|
||||
const elements = heavy.length ? `\n elements = { ${heavy.join(", ")} }` : "";
|
||||
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
|
||||
await execStdin("nft", ["-f", "-"], `
|
||||
add table inet ${NFT_TABLE}
|
||||
delete table inet ${NFT_TABLE}
|
||||
table inet ${NFT_TABLE} {
|
||||
set heavy {
|
||||
type ipv4_addr${elements}
|
||||
}
|
||||
chain postrouting {
|
||||
type filter hook postrouting priority -150; policy accept;
|
||||
oifname "${iface}" ip daddr @heavy ip dscp set cs1
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
async function hasCake(dev: string): Promise<boolean> {
|
||||
try { return /\bcake\b/.test(await exec("tc", ["qdisc", "show", "dev", dev, "root"])); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function hasIngressRedirect(): Promise<boolean> {
|
||||
try { return /mirred/.test(await exec("tc", ["filter", "show", "dev", iface, "parent", "ffff:"])); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function applyNow() {
|
||||
const s = settings();
|
||||
if (!s.enabled || s.downMbit <= 0 || s.upMbit <= 0) { await removeNow(); return; }
|
||||
const oh = cakeOverhead();
|
||||
|
||||
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
|
||||
await exec("tc", ["qdisc", "replace", "dev", iface, "root", "cake",
|
||||
"bandwidth", `${s.downMbit}mbit`, "diffserv3", "dual-dsthost", "overhead", String(oh)]);
|
||||
|
||||
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
|
||||
try { await exec("ip", ["link", "show", IFB]); }
|
||||
catch { await exec("ip", ["link", "add", IFB, "type", "ifb"]); }
|
||||
await exec("ip", ["link", "set", IFB, "up"]);
|
||||
await exec("tc", ["qdisc", "replace", "dev", IFB, "root", "cake",
|
||||
"bandwidth", `${s.upMbit}mbit`, "besteffort", "dual-srchost", "overhead", String(oh)]);
|
||||
if (!(await hasIngressRedirect())) {
|
||||
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
|
||||
await exec("tc", ["qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"]);
|
||||
await exec("tc", ["filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
|
||||
"matchall", "action", "mirred", "egress", "redirect", "dev", IFB]);
|
||||
}
|
||||
|
||||
await nftApply([...heavySet]);
|
||||
lastError = "";
|
||||
logger.info("shaping applied", { down: s.downMbit, up: s.upMbit, overhead: oh, heavy: heavySet.size });
|
||||
}
|
||||
|
||||
async function removeNow() {
|
||||
await exec("tc", ["qdisc", "del", "dev", iface, "root"]).catch(() => undefined);
|
||||
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
|
||||
await exec("ip", ["link", "del", IFB]).catch(() => undefined);
|
||||
await exec("nft", ["delete", "table", "inet", NFT_TABLE]).catch(() => undefined);
|
||||
heavySet.clear(); history.clear(); totals.length = 0; congested = false;
|
||||
}
|
||||
|
||||
// Не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от ensureInterfaceUp).
|
||||
function apply(): Promise<void> {
|
||||
return serial(applyNow).catch(noteError);
|
||||
}
|
||||
|
||||
async function update(s: ShapingSettings): Promise<void> {
|
||||
save(s);
|
||||
await serial(applyNow); // пользователю нужна честная ошибка, а не молчаливый warn
|
||||
lastError = "";
|
||||
}
|
||||
|
||||
// ───────────── сэмплер (RFC 6057) ─────────────
|
||||
const prev = new Map<string, { rx: number; tx: number }>();
|
||||
const history = new Map<string, Tick[]>(); // загрузка по IP
|
||||
const totals: Tick[] = []; // суммарная загрузка (всё, что ушло клиентам)
|
||||
const heavySet = new Set<string>();
|
||||
let congested = false;
|
||||
let lastRateDown = 0, lastRateUp = 0;
|
||||
let lastTickAt = 0;
|
||||
let tickNo = 0;
|
||||
const windowTicks = Math.max(3, Math.round(WINDOW_S / SAMPLE_S));
|
||||
|
||||
const push = (arr: Tick[], t: Tick) => { arr.push(t); while (arr.length > windowTicks) arr.shift(); };
|
||||
const mbit = (arr: Tick[]) => {
|
||||
const dt = arr.reduce((a, t) => a + t.dt, 0);
|
||||
return dt > 0 ? arr.reduce((a, t) => a + t.bytes, 0) * 8 / dt / 1e6 : 0;
|
||||
};
|
||||
|
||||
async function tick() {
|
||||
const s = settings();
|
||||
if (!s.enabled) return;
|
||||
const now = Date.now();
|
||||
const dt = lastTickAt ? (now - lastTickAt) / 1000 : SAMPLE_S;
|
||||
lastTickAt = now;
|
||||
tickNo++;
|
||||
|
||||
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
|
||||
if (tickNo % CHECK_EVERY === 0 && !(await hasCake(iface))) {
|
||||
logger.warn("shaping: qdisc на интерфейсе пропал, применяю заново");
|
||||
await apply();
|
||||
}
|
||||
|
||||
let peers: PeerCounters[];
|
||||
try { peers = await deps.peers(); } catch (e) { noteError(e); return; }
|
||||
|
||||
let totalDown = 0, totalUp = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const p of peers) {
|
||||
seen.add(p.ip);
|
||||
const before = prev.get(p.ip);
|
||||
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира → отрицательная дельта = 0.
|
||||
const dTx = before && p.tx >= before.tx ? p.tx - before.tx : 0;
|
||||
const dRx = before && p.rx >= before.rx ? p.rx - before.rx : 0;
|
||||
prev.set(p.ip, { rx: p.rx, tx: p.tx });
|
||||
totalDown += dTx; totalUp += dRx;
|
||||
let h = history.get(p.ip);
|
||||
if (!h) history.set(p.ip, h = []);
|
||||
push(h, { dt, bytes: dTx });
|
||||
}
|
||||
for (const ip of [...prev.keys()]) if (!seen.has(ip)) { prev.delete(ip); history.delete(ip); heavySet.delete(ip); }
|
||||
push(totals, { dt, bytes: totalDown });
|
||||
lastRateDown = totalDown * 8 / dt / 1e6;
|
||||
lastRateUp = totalUp * 8 / dt / 1e6;
|
||||
|
||||
if (!s.auto) {
|
||||
if (heavySet.size || congested) { heavySet.clear(); congested = false; await serial(() => nftApply([])).catch(noteError); }
|
||||
return;
|
||||
}
|
||||
if (totals.length < windowTicks) return; // пока окно не набралось, решений не принимаем
|
||||
|
||||
const avg = mbit(totals);
|
||||
const wasCongested = congested;
|
||||
if (!congested && avg >= CONGEST_ON * s.downMbit) congested = true;
|
||||
if ( congested && avg < CONGEST_OFF * s.downMbit) congested = false;
|
||||
|
||||
const nextHeavy = new Set<string>();
|
||||
let active = 0;
|
||||
if (congested) {
|
||||
const rates = [...history.entries()].filter(([, h]) => h.length >= windowTicks).map(([ip, h]) => [ip, mbit(h)] as const);
|
||||
active = rates.filter(([, r]) => r >= ACTIVE_MBIT).length;
|
||||
const share = s.downMbit / Math.max(1, active);
|
||||
for (const [ip, r] of rates) {
|
||||
const wasHeavy = heavySet.has(ip);
|
||||
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
|
||||
if (r >= Math.max(HEAVY_MULT * share, HEAVY_MIN_FRAC * s.downMbit) || (wasHeavy && r >= share)) nextHeavy.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
const changed = nextHeavy.size !== heavySet.size || [...nextHeavy].some(ip => !heavySet.has(ip));
|
||||
if (changed || wasCongested !== congested) {
|
||||
heavySet.clear(); nextHeavy.forEach(ip => heavySet.add(ip));
|
||||
logger.info("shaping: класс тяжёлых обновлён", { congested, avgMbit: Math.round(avg), heavy: [...heavySet] });
|
||||
await serial(() => nftApply([...heavySet])).catch(noteError);
|
||||
}
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
function start() {
|
||||
if (timer) return;
|
||||
let busy = false;
|
||||
timer = setInterval(() => {
|
||||
if (busy) return; // предыдущий тик ещё идёт — пропускаем, а не копим очередь
|
||||
busy = true;
|
||||
tick().catch(noteError).finally(() => { busy = false; });
|
||||
}, SAMPLE_S * 1000);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
// ───────────── состояние ─────────────
|
||||
async function qdiscStats(dev: string): Promise<QdiscStats | undefined> {
|
||||
try {
|
||||
const arr = JSON.parse(await exec("tc", ["-s", "-j", "qdisc", "show", "dev", dev, "root"])) as Record<string, unknown>[];
|
||||
const q = arr.find(x => x.kind === "cake");
|
||||
if (!q) return undefined;
|
||||
return {
|
||||
packets: Number(q.packets) || 0, bytes: Number(q.bytes) || 0,
|
||||
drops: Number(q.drops) || 0, backlog: Number(q.backlog) || 0,
|
||||
};
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
async function status(): Promise<ShapingStatus> {
|
||||
const s = settings();
|
||||
const [active, down, up] = s.enabled
|
||||
? await Promise.all([hasCake(iface), qdiscStats(iface), qdiscStats(IFB)])
|
||||
: [false, undefined, undefined];
|
||||
return {
|
||||
settings: s, active, congested, heavy: [...heavySet],
|
||||
devices: [...history.values()].filter(h => mbit(h) >= ACTIVE_MBIT).length,
|
||||
rateDown: Math.round(lastRateDown * 10) / 10,
|
||||
rateUp: Math.round(lastRateUp * 10) / 10,
|
||||
down, up,
|
||||
};
|
||||
}
|
||||
|
||||
// Текущий приоритет устройства по его внутреннему IP: «фоновый» — пока он в bulk-классе.
|
||||
const priorityOf = (ip: string): "normal" | "background" => (heavySet.has(ip) ? "background" : "normal");
|
||||
|
||||
return { settings, update, apply, start, status, priorityOf };
|
||||
}
|
||||
|
||||
export type Shaping = ReturnType<typeof createShaping>;
|
||||
@@ -9,6 +9,6 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
""
|
||||
"index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AWG Panel</title>
|
||||
<title>Вход</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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: GET /metrics на ядре. Метрики ядра и всех нод собираются здесь же —
|
||||
// у нод через уже открытый WSS (awg-agent пропускает только /api/*, поэтому на самих
|
||||
// нодах отдельного порта для скрейпа не нужно). Доступ — отдельный токен «только
|
||||
// чтение метрик»; он не открывает ни /api/v1, ни панель.
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
interface ServerInfo {
|
||||
id: number; name: string; local: boolean; online: boolean; pending: boolean;
|
||||
version: string | null;
|
||||
health: { gen: string; module: string; tools: string } | null;
|
||||
}
|
||||
|
||||
interface MetricsDeps {
|
||||
uidb: Database.Database;
|
||||
version: string;
|
||||
servers: {
|
||||
list: () => ServerInfo[];
|
||||
isOnline: (id: number) => boolean;
|
||||
ctrlFor: (id: number) => (method: string, urlPath: string) => Promise<{ status: number; data: any }>;
|
||||
};
|
||||
}
|
||||
|
||||
const SCRAPE_DEADLINE_MS = 5_000;
|
||||
const TOUCH_EVERY_S = 60; // last_used пишем не чаще раза в минуту, а не на каждый скрейп
|
||||
|
||||
const hashToken = (t: string) => crypto.createHash("sha256").update(t).digest();
|
||||
|
||||
// Токен встроенного Prometheus (`awg-ctrl monitor install`): сам токен лежит только у Prometheus,
|
||||
// здесь — его хэш. Файл читаем при каждой проверке, пересчитывая по mtime: установка не требует перезапуска панели.
|
||||
const LOCAL_HASH_FILE = process.env.MONITOR_HASH_FILE ?? "/etc/amnezia/amneziawg/monitor_token.sha256";
|
||||
let localHash: { mtime: number; hash: Buffer | null } = { mtime: -1, hash: null };
|
||||
function localTokenHash(): Buffer | null {
|
||||
try {
|
||||
const mtime = fs.statSync(LOCAL_HASH_FILE).mtimeMs;
|
||||
if (mtime !== localHash.mtime) {
|
||||
const hex = fs.readFileSync(LOCAL_HASH_FILE, "utf8").trim();
|
||||
localHash = { mtime, hash: /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null };
|
||||
}
|
||||
return localHash.hash;
|
||||
} catch { localHash = { mtime: -1, hash: null }; return null; }
|
||||
}
|
||||
|
||||
// Пер-пировые серии (метка peer = внутренний IP, name = имя vpn://-пользователя) выключены:
|
||||
// это и приватность, и кардинальность. Включается явно.
|
||||
const PER_PEER = (process.env.METRICS_PEERS ?? "off").toLowerCase() === "on";
|
||||
|
||||
type Labels = Record<string, string | number>;
|
||||
type Sample = [Labels, number];
|
||||
|
||||
const esc = (v: string | number) => String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
|
||||
const fmtLabels = (l: Labels) => {
|
||||
const parts = Object.entries(l).map(([k, v]) => `${k}="${esc(v)}"`);
|
||||
return parts.length ? `{${parts.join(",")}}` : "";
|
||||
};
|
||||
|
||||
class Exposition {
|
||||
private lines: string[] = [];
|
||||
private seen = new Set<string>();
|
||||
add(name: string, type: "gauge" | "counter", help: string, samples: Sample[]) {
|
||||
if (!samples.length) return;
|
||||
if (!this.seen.has(name)) {
|
||||
this.seen.add(name);
|
||||
this.lines.push(`# HELP ${name} ${help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")}`, `# TYPE ${name} ${type}`);
|
||||
}
|
||||
for (const [l, v] of samples) this.lines.push(`${name}${fmtLabels(l)} ${Number.isFinite(v) ? v : 0}`);
|
||||
}
|
||||
toString() { return this.lines.join("\n") + "\n"; }
|
||||
}
|
||||
|
||||
export function createMetrics(deps: MetricsDeps) {
|
||||
const { uidb } = deps;
|
||||
|
||||
uidb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS metrics_token (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
token_hash BLOB NOT NULL,
|
||||
prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER
|
||||
)
|
||||
`);
|
||||
const q = {
|
||||
get: uidb.prepare("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1"),
|
||||
set: uidb.prepare("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)"),
|
||||
del: uidb.prepare("DELETE FROM metrics_token WHERE id = 1"),
|
||||
touch: uidb.prepare("UPDATE metrics_token SET last_used = ? WHERE id = 1"),
|
||||
};
|
||||
type TokenRow = { token_hash: Buffer; prefix: string; created_at: number; last_used: number | null };
|
||||
|
||||
// ── Управление токеном (роуты панели, за JWT) ──
|
||||
const router = Router();
|
||||
const view = () => {
|
||||
const r = q.get.get() as TokenRow | undefined;
|
||||
return r ? { exists: true, prefix: r.prefix, created_at: r.created_at, last_used: r.last_used, per_peer: PER_PEER }
|
||||
: { exists: false, per_peer: PER_PEER };
|
||||
};
|
||||
router.get("/", (_req: Request, res: Response) => { res.json(view()); });
|
||||
// Выпуск и перевыпуск — одно и то же: старый токен перестаёт работать. Значение отдаётся один раз.
|
||||
router.post("/", (_req: Request, res: Response) => {
|
||||
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
|
||||
q.set.run(hashToken(token), token.slice(0, 13) + "…", Math.floor(Date.now() / 1000));
|
||||
res.status(201).json({ ...view(), token });
|
||||
});
|
||||
router.delete("/", (_req: Request, res: Response) => { q.del.run(); res.json(view()); });
|
||||
|
||||
// ── Доступ к /metrics ──
|
||||
// Нет токена или он неверный — одинаковый 401: не подсказываем, выключены метрики или токен не тот.
|
||||
function requireToken(req: Request, res: Response, next: NextFunction) {
|
||||
const h = req.headers.authorization ?? "";
|
||||
const given = h.startsWith("Bearer ") ? h.slice(7) : "";
|
||||
const row = q.get.get() as TokenRow | undefined;
|
||||
const gh = given.startsWith("awgm_") ? hashToken(given) : null;
|
||||
const panelOk = !!row && !!gh && crypto.timingSafeEqual(gh, row.token_hash);
|
||||
const local = localTokenHash();
|
||||
const localOk = !!gh && !!local && crypto.timingSafeEqual(gh, local);
|
||||
if (!panelOk && !localOk) { res.status(401).set("WWW-Authenticate", "Bearer").type("text/plain").send("unauthorized\n"); return; }
|
||||
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (panelOk && row && (!row.last_used || now - row.last_used >= TOUCH_EVERY_S)) q.touch.run(now);
|
||||
next();
|
||||
}
|
||||
|
||||
// Один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
|
||||
async function scrapeServer(s: ServerInfo): Promise<any | null> {
|
||||
if (s.pending || !deps.servers.isOnline(s.id)) return null;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const r = await Promise.race([
|
||||
deps.servers.ctrlFor(s.id)("GET", "/api/metrics"),
|
||||
new Promise<never>((_, rej) => { timer = setTimeout(() => rej(new Error("deadline")), SCRAPE_DEADLINE_MS); }),
|
||||
]);
|
||||
return r.status === 200 ? r.data : null;
|
||||
} catch { return null; }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
async function render(): Promise<string> {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const list = deps.servers.list();
|
||||
const data = await Promise.all(list.map(scrapeServer));
|
||||
const out = new Exposition();
|
||||
const L = (s: ServerInfo, extra: Labels = {}): Labels => ({ server: s.name, server_id: s.id, ...extra });
|
||||
// Серии только тех серверов, чьи метрики получены; fn всегда отдаёт массив серий.
|
||||
const per = (fn: (s: ServerInfo, d: any) => Sample[]): Sample[] =>
|
||||
list.flatMap((s, i) => (data[i] ? fn(s, data[i]) : []));
|
||||
|
||||
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", [[{ version: deps.version }, 1]]);
|
||||
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", [[{}, list.length]]);
|
||||
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", list.map(s => [L(s), s.online ? 1 : 0] as Sample));
|
||||
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", list.map((s, i) => [L(s), data[i] ? 1 : 0] as Sample));
|
||||
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.",
|
||||
list.filter(s => !s.pending).map(s => [L(s, { version: s.version ?? "", gen: s.health?.gen ?? "", module: s.health?.module ?? "", tools: s.health?.tools ?? "" }), 1] as Sample));
|
||||
|
||||
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per((s, d) => [[L(s), d.up ? 1 : 0] as Sample]));
|
||||
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per((s, d) => [[L(s), d.peers?.total ?? 0] as Sample]));
|
||||
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per((s, d) => [[L(s), d.peers?.online ?? 0] as Sample]));
|
||||
// Сумма по пирам — gauge, а не counter: при удалении пира или смене его ключа сумма уменьшается,
|
||||
// и rate() принял бы это за сброс счётчика с ложным всплеском. Для скорости берите awg_link_*_bytes_total.
|
||||
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.rx_bytes ?? 0] as Sample]));
|
||||
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.tx_bytes ?? 0] as Sample]));
|
||||
for (const [k, name, help] of [
|
||||
["rx_bytes", "receive_bytes", "байт"], ["tx_bytes", "transmit_bytes", "байт"],
|
||||
["rx_packets", "receive_packets", "пакетов"], ["tx_packets", "transmit_packets", "пакетов"],
|
||||
["rx_dropped", "receive_dropped", "потерянных пакетов"], ["tx_dropped", "transmit_dropped", "потерянных пакетов"],
|
||||
["rx_errors", "receive_errors", "ошибок"], ["tx_errors", "transmit_errors", "ошибок"],
|
||||
] as const) {
|
||||
out.add(`awg_link_${name}_total`, "counter", `Интерфейс awg1: ${help} (${k}).`, per((s, d) => [[L(s), d.link?.[k] ?? 0] as Sample]));
|
||||
}
|
||||
|
||||
// Нагрузка хоста (ядро и ноды). CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
|
||||
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
|
||||
per((s, d) => Object.entries(d.host?.cpu ?? {}).map(([mode, v]) => [L(s, { mode }), Number(v)] as Sample)));
|
||||
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per((s, d) => d.host ? [[L(s), d.host.cores ?? 0] as Sample] : []));
|
||||
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.total ?? 0] as Sample] : []));
|
||||
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.available ?? 0] as Sample] : []));
|
||||
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).",
|
||||
per((s, d) => d.host ? (["1m", "5m", "15m"] as const).map((p, i) => [L(s, { period: p }), d.host.load?.[i] ?? 0] as Sample) : []));
|
||||
|
||||
// Деление канала (CAKE)
|
||||
const sh = (fn: (d: any) => number) => per((s, d) => d.shaping ? [[L(s), fn(d.shaping)] as Sample] : []);
|
||||
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(x => x.settings?.enabled ? 1 : 0));
|
||||
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(x => x.active ? 1 : 0));
|
||||
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(x => x.congested ? 1 : 0));
|
||||
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(x => x.heavy?.length ?? 0));
|
||||
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(x => x.devices ?? 0));
|
||||
const dirs = (fn: (x: any, dir: "down" | "up") => number | undefined) =>
|
||||
per((s, d) => d.shaping ? (["down", "up"] as const).flatMap(dir => {
|
||||
const v = fn(d.shaping, dir);
|
||||
return v === undefined ? [] : [[L(s, { direction: dir }), v] as Sample];
|
||||
}) : []);
|
||||
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs((x, dir) => (dir === "down" ? x.settings?.downMbit : x.settings?.upMbit) * 1e6));
|
||||
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs((x, dir) => (dir === "down" ? x.rateDown : x.rateUp) * 1e6));
|
||||
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs((x, dir) => x[dir]?.drops));
|
||||
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs((x, dir) => x[dir]?.backlog));
|
||||
|
||||
if (PER_PEER) {
|
||||
const peers = (fn: (p: any) => number) => per((s, d) => (d.list ?? []).map((p: any) =>
|
||||
[L(s, { peer: p.ip, name: p.name ?? "", kind: p.kind }), fn(p)] as Sample));
|
||||
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(p => p.online ? 1 : 0));
|
||||
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(p => p.priority === "background" ? 1 : 0));
|
||||
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(p => p.rx));
|
||||
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(p => p.tx));
|
||||
}
|
||||
|
||||
let masters = 0, devices = 0;
|
||||
// Таблицы создаёт sub: готовим запросы здесь, а не при создании модуля, — порядок
|
||||
// инициализации в server.ts не должен решать, упадёт ли панель на старте.
|
||||
try {
|
||||
masters = (uidb.prepare("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").get() as { n: number }).n;
|
||||
devices = (uidb.prepare("SELECT COUNT(*) AS n FROM devices").get() as { n: number }).n;
|
||||
} catch { /* sub ещё не создал таблицы */ }
|
||||
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", [[{}, masters]]);
|
||||
out.add("awg_devices", "gauge", "Устройств sen://.", [[{}, devices]]);
|
||||
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", [[{}, Number(process.hrtime.bigint() - t0) / 1e9]]);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
async function handler(_req: Request, res: Response) {
|
||||
try {
|
||||
res.type("text/plain; version=0.0.4; charset=utf-8").send(await render());
|
||||
} catch (e) {
|
||||
console.warn(`metrics: ${(e as Error).message}`);
|
||||
res.status(500).type("text/plain").send("error\n");
|
||||
}
|
||||
}
|
||||
|
||||
return { router, requireToken, handler };
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// 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.
|
||||
//
|
||||
// Локальный мок для разработки фронтенда: эмулирует awg-ui + awg-ctrl целиком в
|
||||
// памяти, AWG не нужен. Логин admin / admin. Запуск: npm run dev:mock.
|
||||
import express from "express";
|
||||
import crypto from "crypto";
|
||||
import { encodeSenLink } from "./sen";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const rnd = (n: number) => Math.floor(Math.random() * n);
|
||||
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
||||
const GEN = "3.1";
|
||||
const MOCK_VERSION = "0.5.0";
|
||||
|
||||
// ── пользователи (vpn://) ──
|
||||
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({
|
||||
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
|
||||
key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
|
||||
}));
|
||||
|
||||
// ── серверы: core (id 0), нода в сети и нода, ещё не подключавшаяся ──
|
||||
// Пользователи хранятся по серверам; X-Server-Id выбирает набор, как в настоящей панели.
|
||||
const usersOf: Record<number, U[]> = {
|
||||
0: users,
|
||||
1: ["dave", "erin"].map((name, i) => ({
|
||||
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
|
||||
key_gen: "2", online: i === 0, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
|
||||
})),
|
||||
};
|
||||
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
|
||||
const U_ = (q: express.Request) => usersOf[srv(q)];
|
||||
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
|
||||
const mnodes: MNode[] = [
|
||||
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.5.0" },
|
||||
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
|
||||
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: 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;
|
||||
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");
|
||||
|
||||
// ── API-ключи ──
|
||||
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, servers: [0], created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
|
||||
let apiId = 1;
|
||||
|
||||
// ── мастер-ключи sen:// ──
|
||||
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: 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;
|
||||
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: 2, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1, 2] }, // все места заняты; Tokyo (2) ещё не получил устройства
|
||||
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0, 1] }, // «все серверы»
|
||||
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
|
||||
const signPub = crypto.randomBytes(32);
|
||||
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||
const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id;
|
||||
const devOut = (m: MK, d: Dev) => ({ ...d,
|
||||
priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal",
|
||||
torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length,
|
||||
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
|
||||
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
||||
|
||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
|
||||
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
|
||||
app.post("/logout", (_q, r) => r.json({ ok: true }));
|
||||
app.get("/health", (q, r) => {
|
||||
const id = srv(q), n = mnodes.find(x => x.id === id);
|
||||
r.json({ status: "ok", server: n?.name ?? "VPN (mock)", ip: n?.ip ?? "203.0.113.7", gen: n?.gen ?? GEN, awg: { status: "ok", peers: usersOf[id].length, module: "3.0.1", tools: "1.0.0" } });
|
||||
});
|
||||
|
||||
// ── серверы ──
|
||||
// Как MinNodeVersion в хабе: ноду старше хаб не подключает (в моке она просто offline + outdated)
|
||||
const MIN_NODE = "0.5.0";
|
||||
const cmpVer = (a: string, b: string) => {
|
||||
const pa = a.split("-")[0].split(".").map(Number), pb = b.split("-")[0].split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d; }
|
||||
return 0;
|
||||
};
|
||||
const nodeView = () => [
|
||||
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
|
||||
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version,
|
||||
outdated: !n.pending && (!n.version || cmpVer(n.version, MIN_NODE) < 0),
|
||||
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
|
||||
];
|
||||
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
|
||||
app.post("/ui/nodes", (q, r) => {
|
||||
const name = String(q.body.name ?? "");
|
||||
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
|
||||
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true, version: null });
|
||||
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
|
||||
});
|
||||
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
|
||||
app.delete("/ui/nodes/:id", (q, r) => {
|
||||
const id = Number(q.params.id), i = mnodes.findIndex(x => x.id === id); if (i < 0) return r.status(404).json({ error: "Не найден" });
|
||||
if (masters.some(m => !m.deleting && m.servers.includes(id))) return r.status(409).json({ error: "Сервер в мастер-ключах — сначала уберите его оттуда", can_force: false });
|
||||
const waiting = masters.filter(m => m.deleting?.includes(id));
|
||||
if (waiting.length && q.query.force !== "1")
|
||||
return r.status(409).json({ error: `Сервер не подтвердил, что снял пиров удалённых мастер-ключей (${waiting.map(m => m.label).join(", ")})`, can_force: true });
|
||||
for (const m of waiting) m.deleting = m.deleting!.filter(x => x !== id);
|
||||
for (const m of waiting) if (!m.deleting!.length) masters.splice(masters.indexOf(m), 1);
|
||||
mnodes.splice(i, 1); r.json({ success: true });
|
||||
});
|
||||
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
||||
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
||||
|
||||
// Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере).
|
||||
const MON_LOGS = {
|
||||
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
|
||||
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
|
||||
};
|
||||
// Как internal/ui/monitor.go: Prometheus у панели один. target — чей awg-ctrl ставит и удаляет его:
|
||||
// где он уже стоит; если нигде — свой (панель + VPN) или ?server= (панель без VPN, MOCK_ROLE=core).
|
||||
// Ход операции у каждого сервера свой (его awg-ctrl). Ставить можно, только если awg-ctrl сервера
|
||||
// на связи и новый: Tokyo без связи, у Amsterdam старый агент (/api/monitor/job → 404) — у них
|
||||
// can_install: false и команда вручную; у Frankfurt осталась неудачная прошлая установка.
|
||||
type MonJob = { action: "install" | "remove"; start: number; lines?: string[]; exit?: number };
|
||||
const monJobs: Record<number, MonJob> = {
|
||||
1: { action: "install", start: now() * 1000 - 86400e3, exit: 100, lines: ["1/4 установка пакета prometheus", "Reading package lists…",
|
||||
"E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1873 (unattended-upgr)", "E: Unable to acquire the dpkg frontend lock, is another process using it?"] },
|
||||
};
|
||||
let monReady = false;
|
||||
let monHost = 0; // где стоит (для панели без VPN — нода; у панели с VPN всегда 0, «этот сервер»)
|
||||
const monHostOf = (id: number) => ({ id, name: nodeView().find(n => n.id === id)?.name ?? `#${id}` });
|
||||
const MON_MS = 6000;
|
||||
const monRunning = (j?: MonJob) => !!j && j.exit === undefined && Date.now() - j.start < MON_MS;
|
||||
const monTarget = (q: express.Request) => monReady || !MOCK_CORE ? monHost : Number(q.query.server ?? 0);
|
||||
const monCan = (id: number) => { const n = mnodes.find(x => x.id === id); return !n || (n.online && n.version !== null); };
|
||||
app.get("/ui/monitor", (q, r) => {
|
||||
const target = monTarget(q), can = monCan(target), mj = monJobs[target];
|
||||
if (mj && mj.exit === undefined && !monRunning(mj)) { // операция дошла до конца
|
||||
mj.exit = 0;
|
||||
monReady = mj.action === "install";
|
||||
if (monReady) monHost = target;
|
||||
}
|
||||
let job = null;
|
||||
if (can && mj) {
|
||||
const lines = mj.lines ?? MON_LOGS[mj.action];
|
||||
const n = monRunning(mj) ? Math.max(1, Math.floor(((Date.now() - mj.start) / MON_MS) * lines.length)) : lines.length;
|
||||
job = { action: mj.action, running: monRunning(mj), exit: monRunning(mj) ? null : mj.exit ?? 0, log: lines.slice(0, n).join("\n") };
|
||||
}
|
||||
r.json({ ready: monReady, can_install: can, command: "awg-ctrl monitor install", host: MOCK_CORE ? monHostOf(target) : null, job });
|
||||
});
|
||||
for (const action of ["install", "remove"] as const) {
|
||||
app.post(`/ui/monitor/${action}`, (q, r) => {
|
||||
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) => {
|
||||
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 to = Math.floor(Date.now() / step) * step, from = to - sec;
|
||||
const pts = (f: (i: number, t: number) => number): [number, number][] => Array.from({ length: Math.floor(sec / step) + 1 }, (_, i) => [from + i * step, Math.max(0, f(i, from + i * step))]);
|
||||
const wave = (t: number, per: number, ph = 0) => Math.sin((t / per) * 2 * Math.PI + ph);
|
||||
const per = sec / 3;
|
||||
const down = pts((_, t) => 110e6 + 70e6 * wave(t, per) + 25e6 * wave(t, per / 7) + rnd(12e6));
|
||||
const up = pts((_, t) => 14e6 + 7e6 * wave(t, per, 1) + rnd(3e6));
|
||||
const online = pts((_, t) => Math.round(9 + 3 * wave(t, per * 1.6) + (rnd(100) > 90 ? 1 : 0)));
|
||||
const heavy = pts((_, t) => (wave(t, per) > 0.55 ? 1 : 0));
|
||||
const drops = pts((_, t) => (wave(t, per) > 0.55 ? 3 + rnd(6) : rnd(10) > 8 ? 0.4 : 0));
|
||||
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
|
||||
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
|
||||
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
|
||||
const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты
|
||||
// Ряды одного сервера: ядро (0) — основная нагрузка, нода Frankfurt (1) — поменьше
|
||||
const one = (id: number) => {
|
||||
const k = id === 0 ? 0.7 : 0.3;
|
||||
const d = scale(down, k), u = scale(up, k);
|
||||
return {
|
||||
row: { id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: d.at(-1)![1], up: u.at(-1)![1], online: id === 0 ? 7 : 3,
|
||||
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
|
||||
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30,
|
||||
used_down: used(d), used_up: used(u) },
|
||||
series: { down: d, up: u, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
|
||||
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) },
|
||||
};
|
||||
};
|
||||
const empty = { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] };
|
||||
// Без ?server= — все серверы (как monitor.go): ряды трафика, устройств и потерь суммируются, процессор
|
||||
// и память — среднее. Метрики отдают ядро и Frankfurt; у Amsterdam (3) старый агент — его нет.
|
||||
if (q.query.server === undefined) {
|
||||
const all = [one(0), one(1)];
|
||||
const zip = (key: keyof typeof empty, avg: boolean) => all[0].series[key].map(([t], n) => {
|
||||
const sum = all.reduce((acc, x) => acc + x.series[key][n][1], 0);
|
||||
return [t, avg ? sum / all.length : sum] as [number, number];
|
||||
});
|
||||
return r.json({ range: q.query.range ?? "1h", step, from, to, servers: all.map(x => x.row),
|
||||
total: { down: zip("down", false), up: zip("up", false), online: zip("online", false), heavy: zip("heavy", false),
|
||||
drops: zip("drops", false), cpu: zip("cpu", true), mem: zip("mem", true) } });
|
||||
}
|
||||
// Один сервер (?server=<id>)
|
||||
const id = Number(q.query.server);
|
||||
if (id !== 0 && id !== 1) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: empty });
|
||||
const x = one(id);
|
||||
r.json({ range: q.query.range ?? "1h", step, from, to, servers: [x.row], total: x.series });
|
||||
});
|
||||
|
||||
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
|
||||
let promTok: { prefix: string; created_at: number; last_used: number | null } | null = null;
|
||||
const promView = () => promTok ? { exists: true, ...promTok, per_peer: false } : { exists: false, per_peer: false };
|
||||
app.get("/ui/metrics-token", (_q, r) => r.json(promView()));
|
||||
app.post("/ui/metrics-token", (_q, r) => {
|
||||
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
|
||||
promTok = { prefix: token.slice(0, 13) + "…", created_at: now(), last_used: null };
|
||||
r.status(201).json({ ...promView(), token });
|
||||
});
|
||||
app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView()); });
|
||||
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
|
||||
|
||||
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
|
||||
// На сервере 0 деление канала включено сразу — чтобы был пример «фонового» приоритета: пользователь
|
||||
// 10.9.0.2 и «MacBook Ивана» в мастер-ключе «Семья».
|
||||
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = { 0: { enabled: true, downMbit: 100, upMbit: 50, auto: true } };
|
||||
const shapingView = (id: number) => {
|
||||
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
|
||||
const on = settings.enabled;
|
||||
return {
|
||||
settings, active: on, congested: on && id === 0,
|
||||
heavy: on && id === 0 ? ["10.9.0.2", ...(torrentOf[0]?.enabled ? [TORRENT_IP, "10.9.0.12"] : [])] : [],
|
||||
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
|
||||
};
|
||||
};
|
||||
app.get("/api/shaping", (q, r) => srv(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(shapingView(srv(q))));
|
||||
app.put("/api/shaping", (q, r) => {
|
||||
const id = srv(q), b = q.body ?? {};
|
||||
if (id === 1) return r.status(404).json({ error: "Not found" });
|
||||
if (b.enabled && !(b.downMbit >= 1 && b.upMbit >= 1)) return r.status(400).json({ error: "Для включения укажите ширину канала в обе стороны" });
|
||||
shapingOf[id] = { enabled: !!b.enabled, downMbit: Number(b.downMbit) || 0, upMbit: Number(b.upMbit) || 0, auto: !!b.auto };
|
||||
r.json(shapingView(id));
|
||||
});
|
||||
|
||||
// Блокировка торрентов: на сервере 0 качает пользователь 10.9.0.3 и второе устройство первого мастер-ключа;
|
||||
// Amsterdam (3) изображает старый nftables без TCP-проверки; Frankfurt (1) — старая нода без маршрута (404).
|
||||
// Сервер берём прямо из X-Server-Id: srv() подменяет сервер без своих пользователей на 0.
|
||||
const torrentOf: Record<number, { enabled: boolean; since: number }> = {};
|
||||
const tsid = (q: express.Request) => Number(q.headers["x-server-id"] ?? 0) || 0;
|
||||
const TORRENT_IP = "10.9.0.3";
|
||||
const torrentSeenIp = (id: number, ip: string) => id === 0 && !!torrentOf[0]?.enabled && ip === TORRENT_IP;
|
||||
const torrentView = (id: number) => {
|
||||
const t = torrentOf[id] ?? { enabled: false, since: 0 };
|
||||
const seen = id === 0 && t.enabled
|
||||
? [{ ip: TORRENT_IP, name: users.find(u => u.ip === TORRENT_IP)?.name ?? "" }, { ip: "10.9.0.12", name: "" }]
|
||||
: [];
|
||||
return { settings: { enabled: t.enabled }, active: t.enabled, tcp: t.enabled && id !== 3,
|
||||
hits: t.enabled && id === 0 ? 1840 + Math.floor((now() - t.since) * 3.7) : 0, seen };
|
||||
};
|
||||
app.get("/api/torrent", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(torrentView(tsid(q))));
|
||||
app.put("/api/torrent", (q, r) => {
|
||||
const id = tsid(q), b = q.body ?? {};
|
||||
if (id === 1) return r.status(404).json({ error: "Not found" });
|
||||
if (typeof b.enabled !== "boolean") return r.status(400).json({ error: "enabled должен быть true/false" });
|
||||
torrentOf[id] = { enabled: b.enabled, since: b.enabled && !torrentOf[id]?.enabled ? now() : (torrentOf[id]?.since ?? now()) };
|
||||
r.json(torrentView(id));
|
||||
});
|
||||
|
||||
// Маскировка первого пакета (I1): у каждого сервера своя; Frankfurt (1) — старая нода без маршрута (404).
|
||||
// «Сменить» берёт случайный шаблон из примеров — как GenI1 в awg-ctrl, только без случайных полей.
|
||||
const I1_SAMPLES = [
|
||||
{ kind: "dns", label: "DNS-запрос к ya.ru", i1: "<r 2><b 0x01000001000000000001027961027275000001000100002904d0000000000000>" },
|
||||
{ kind: "quic", label: "QUIC Initial, 1250 байт", i1: "<b 0xc30000000108><r 8><b 0x00><b 0x0044e2><r 1232>" },
|
||||
{ kind: "stun", label: "STUN binding request", i1: "<b 0x000100002112a442><r 12>" },
|
||||
{ kind: "dns", label: "DNS-запрос к www.google.com", i1: "<r 2><b 0x010000010000000000000377777706676f6f676c6503636f6d0000410001>" },
|
||||
{ kind: "quic", label: "QUIC Initial, 1200 байт", i1: "<b 0xc90000000108><r 8><b 0x08><r 8><b 0x0044a6><r 1190>" },
|
||||
];
|
||||
const maskingOf: Record<number, number> = { 0: 0, 2: 1, 3: 2 };
|
||||
app.get("/api/masking", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(I1_SAMPLES[maskingOf[tsid(q)] ?? 3]));
|
||||
app.post("/api/masking", (q, r) => {
|
||||
const id = tsid(q);
|
||||
if (id === 1) return r.status(404).json({ error: "Not found" });
|
||||
const cur = maskingOf[id] ?? 3;
|
||||
maskingOf[id] = (cur + 1 + Math.floor(Math.random() * (I1_SAMPLES.length - 1))) % I1_SAMPLES.length;
|
||||
r.json(I1_SAMPLES[maskingOf[id]]);
|
||||
});
|
||||
|
||||
// Журнал подключений (вкладка «Подключения»): несколько событий за двое суток.
|
||||
app.get("/ui/connlog", (q, r) => {
|
||||
if (q.query.before) return r.json({ events: [], peers: [], since: null, keep_days: 30 });
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const who = [
|
||||
{ pub_key: "aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9d=", name: "alice", owner: "", ip: "10.9.0.2" },
|
||||
{ pub_key: "Zx8Cv6Bn4Mq2We0Rt8Yu6Io4Pa2Sd0Fg8Hj6Kl4Zx2C=", name: "", owner: "mk1", ip: "10.9.0.7", key: "Семья", device: "Pixel 7 Pro" },
|
||||
{ pub_key: "Qw1Er3Ty5Ui7Op9As1Df3Gh5Jk7Lz9Xc1Vb3Nm5Qw7E=", name: "bob", owner: "", ip: "10.9.0.3" },
|
||||
];
|
||||
const events = [
|
||||
{ ...who[0], id: 6, at: now - 300, event: "connect" },
|
||||
{ ...who[1], id: 5, at: now - 3900, event: "disconnect", duration: 5400, rx: 812e6, tx: 64e6 },
|
||||
{ ...who[2], id: 4, at: now - 9000, event: "disconnect", duration: 640, rx: 12e6, tx: 2e6 },
|
||||
{ ...who[2], id: 3, at: now - 9640, event: "connect" },
|
||||
{ ...who[1], id: 2, at: now - 9300, event: "connect" },
|
||||
{ ...who[0], id: 1, at: now - 90000, event: "disconnect", duration: 7200, rx: 2.1e9, tx: 190e6 },
|
||||
];
|
||||
const peers = [
|
||||
{ ...who[0], online: true, last_seen: now - 20 },
|
||||
{ ...who[1], online: false, last_seen: now - 3900 },
|
||||
{ ...who[2], online: false, last_seen: now - 9000 },
|
||||
];
|
||||
r.json({ events, peers, since: now - 3 * 86400, keep_days: 30 });
|
||||
});
|
||||
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
||||
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
|
||||
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
|
||||
const prioOf = (q: express.Request, ip: string) =>
|
||||
(srv(q) === 0 && shapingOf[0]?.enabled && (ip === "10.9.0.2" || torrentSeenIp(tsid(q), ip)) ? "background" : "normal");
|
||||
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip), torrent: torrentSeenIp(tsid(q), u.ip) })) }));
|
||||
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
|
||||
app.post("/api/users", (q, r) => {
|
||||
const users = U_(q);
|
||||
const name = String(q.body.name ?? "");
|
||||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
|
||||
if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" });
|
||||
const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 };
|
||||
users.push(u); r.status(201).json(u);
|
||||
});
|
||||
app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
|
||||
app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
|
||||
|
||||
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
|
||||
app.post("/ui/apikeys", (q, r) => { const sid = Number(q.body.server_id ?? 0); const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: sid, servers: q.body.servers ?? [sid], created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
|
||||
app.patch("/ui/apikeys/:id", (q, r) => {
|
||||
const k = apiKeys.find(x => x.id === Number(q.params.id)); if (!k) return r.status(404).json({ error: "Не найден" });
|
||||
if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" });
|
||||
k.servers = [...q.body.servers].sort(); if (!k.servers.includes(k.server_id)) k.server_id = k.servers[0];
|
||||
r.json({ id: k.id, server_id: k.server_id, servers: k.servers });
|
||||
});
|
||||
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
|
||||
|
||||
let mockAuditSettings = { clean: false, every_h: 1, max: 10 };
|
||||
let mockCleaned: unknown[] = [];
|
||||
let mockGhost = true;
|
||||
const mockAudit = () => ({ enabled: true, settings: mockAuditSettings, cleaned: mockCleaned, report: { at: Math.floor(Date.now() / 1000) - 600, servers: [
|
||||
{ server_id: 0, status: "ok", ghosts_checked: true, foreign: 0, orphans: [], ghosts: mockGhost ? [
|
||||
{ pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", online: true, lastHandshake: Math.floor(Date.now() / 1000) - 30, seen_before: true } ] : [] } ] } });
|
||||
app.get("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
|
||||
app.post("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
|
||||
app.post("/ui/masterkeys/audit/clean", (_q, r) => {
|
||||
if (mockGhost) mockCleaned = [{ at: Math.floor(Date.now() / 1000), server_id: 0, pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", kind: "ghost", auto: false }, ...mockCleaned];
|
||||
mockGhost = false; r.json(mockAudit());
|
||||
});
|
||||
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
|
||||
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) => {
|
||||
const limit = q.body.device_limit ?? 3;
|
||||
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 ?? mkDefaults ?? [0] };
|
||||
masters.push(m); r.status(201).json(mkOut(m));
|
||||
});
|
||||
app.patch("/ui/masterkeys/:id", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
|
||||
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit;
|
||||
// Как sub.ts: лишние устройства сверх лимита — отвязать, начиная с подключившихся последними.
|
||||
const unbound = [...m.devs].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, Math.max(0, m.devs.length - m.device_limit)).map(d => d.id);
|
||||
m.devs = m.devs.filter(d => !unbound.includes(d.id));
|
||||
r.json({ ...mkOut(m), unbound });
|
||||
});
|
||||
app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); });
|
||||
app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); });
|
||||
app.delete("/ui/masterkeys/:id", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
const pending = m.servers.filter(id => id !== 0 && !mnodes.find(n => n.id === id)?.online);
|
||||
if (pending.length) { m.deleting = pending; m.devs = []; } else masters.splice(masters.indexOf(m), 1);
|
||||
r.json({ success: true, id: m.id, pending });
|
||||
});
|
||||
app.get("/ui/masterkeys/:id/link", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
|
||||
});
|
||||
// MOCK_MK_FAIL=<id ключа> — /devices этого ключа отвечает 502; MOCK_MK_DOWN=<id сервера> — этот сервер «не ответил».
|
||||
app.get("/ui/masterkeys/:id/devices", (q, r) => {
|
||||
const m = find(q.params.id);
|
||||
if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
if (process.env.MOCK_MK_FAIL === String(m.id)) return r.status(502).json({ error: "awg-ctrl недоступен" });
|
||||
const down = Number(process.env.MOCK_MK_DOWN);
|
||||
r.json({ devices: m.devs.map(d => devOut(m, d)), unreachable: m.servers.filter(s => s === down) });
|
||||
});
|
||||
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
|
||||
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
|
||||
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
|
||||
|
||||
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
|
||||
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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 и основные графики прямо в панели.
|
||||
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
|
||||
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
||||
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` (удаление вместе с накопленными данными)
|
||||
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч
|
||||
// Панель сама пакетов не ставит и в Prometheus лезет только фиксированными запросами (не PromQL от клиента).
|
||||
import axios from "axios";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
|
||||
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
|
||||
|
||||
const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090";
|
||||
|
||||
// Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
|
||||
// Дальше 30 дней не заглядываем: столько Prometheus хранит (RETENTION в cli/src/monitor.ts).
|
||||
const RANGES: Record<string, { seconds: number; step: number }> = {
|
||||
"1h": { seconds: 3600, step: 15 },
|
||||
"6h": { seconds: 21600, step: 60 },
|
||||
"24h": { seconds: 86400, step: 300 },
|
||||
"7d": { seconds: 604800, step: 1800 },
|
||||
"30d": { seconds: 2592000, step: 7200 },
|
||||
};
|
||||
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
|
||||
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
|
||||
const RATE_WINDOW = "2m";
|
||||
const withWindow = (q: string, w: string) => q.split(`[${RATE_WINDOW}]`).join(`[${w}]`);
|
||||
|
||||
type Point = [number, number];
|
||||
type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд
|
||||
|
||||
// Запросы по серверам. Трафик берём со счётчиков интерфейса (монотонны), а не с сумм по пирам:
|
||||
// сумма по пирам падает при удалении пира, и rate() рисовал бы ложный всплеск.
|
||||
const QUERIES = {
|
||||
down: 'sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8',
|
||||
up: 'sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8',
|
||||
online: 'sum by (server_id, server) (awg_peers_online)',
|
||||
heavy: 'sum by (server_id, server) (awg_shaping_heavy_devices)',
|
||||
drops: 'sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))',
|
||||
congested: 'max by (server_id, server) (awg_shaping_congested)',
|
||||
scrape_ok: 'max by (server_id, server) (awg_scrape_ok)',
|
||||
// Загрузка процессора: доля времени не в idle/iowait. Память: MemAvailable, а не MemFree (кэш ядра свободен).
|
||||
cpu: '100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))',
|
||||
mem: '100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))',
|
||||
cores: 'max by (server_id, server) (awg_host_cpu_cores)',
|
||||
mem_total: 'max by (server_id, server) (awg_host_memory_total_bytes)',
|
||||
} as const;
|
||||
type Key = keyof typeof QUERIES;
|
||||
|
||||
async function instantQuery(query: string, time: number): Promise<Map<number, number>> {
|
||||
const r = await axios.get(`${PROM_URL}/api/v1/query`, { params: { query, time }, timeout: 8000 });
|
||||
const out = new Map<number, number>();
|
||||
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; value: [number, string] }[]) {
|
||||
const id = Number(m.metric.server_id), v = Number(m.value[1]);
|
||||
if (Number.isInteger(id) && Number.isFinite(v)) out.set(id, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function rangeQuery(query: string, start: number, end: number, step: number): Promise<Series> {
|
||||
const r = await axios.get(`${PROM_URL}/api/v1/query_range`, {
|
||||
params: { query, start, end, step }, timeout: 8000,
|
||||
});
|
||||
const out: Series = new Map();
|
||||
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; values: [number, string][] }[]) {
|
||||
const id = Number(m.metric.server_id);
|
||||
if (!Number.isInteger(id)) continue;
|
||||
out.set(id, {
|
||||
name: m.metric.server ?? String(id),
|
||||
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
|
||||
points: m.values.map(([t, v]) => [t, Number(v)] as Point).filter(p => Number.isFinite(p[1])),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Сумма рядов по меткам времени (Prometheus выравнивает точки по шагу, метки совпадают).
|
||||
function total(series: Series, how: "sum" | "max" = "sum"): Point[] {
|
||||
const acc = new Map<number, number>();
|
||||
for (const s of series.values()) for (const [t, v] of s.points)
|
||||
acc.set(t, how === "sum" ? (acc.get(t) ?? 0) + v : Math.max(acc.get(t) ?? 0, v));
|
||||
return [...acc.entries()].sort((a, b) => a[0] - b[0]);
|
||||
}
|
||||
|
||||
const last = (s?: { points: Point[] }): number | null => s && s.points.length ? s.points[s.points.length - 1][1] : null;
|
||||
|
||||
export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean }) {
|
||||
const router = Router();
|
||||
|
||||
const promReady = async (): Promise<boolean> => {
|
||||
try { return (await axios.get(`${PROM_URL}/-/ready`, { timeout: 1500, validateStatus: () => true })).status === 200; }
|
||||
catch { return false; }
|
||||
};
|
||||
|
||||
// Установка возможна, только если рядом свой awg-ctrl (у панели без собственного VPN его нет —
|
||||
// тогда остаётся команда в терминале) и он достаточно новый, чтобы знать маршрут.
|
||||
type Job = { can: boolean; action: "install" | "remove" | null; running: boolean; exit: number | null; log: string };
|
||||
async function jobState(): Promise<Job> {
|
||||
const none: Job = { can: false, action: null, running: false, exit: null, log: "" };
|
||||
if (!deps.localEnabled) return none;
|
||||
try {
|
||||
const r = await deps.ctrlLocal("GET", "/api/monitor/job");
|
||||
if (r.status !== 200) return none;
|
||||
return { can: true, action: r.data.action ?? null, running: !!r.data.running, exit: r.data.exit ?? null, log: String(r.data.log ?? "") };
|
||||
} catch { return none; }
|
||||
}
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
const [ready, job] = await Promise.all([promReady(), jobState()]);
|
||||
res.json({
|
||||
ready,
|
||||
can_install: job.can,
|
||||
// Последняя операция (установка или удаление) и её лог; null — ещё не запускали
|
||||
job: job.can && job.action ? { action: job.action, running: job.running, exit: job.exit, log: job.log } : null,
|
||||
command: "awg-ctrl monitor install",
|
||||
});
|
||||
});
|
||||
|
||||
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
|
||||
const ask = (action: "install" | "remove") => async (_req: Request, res: Response) => {
|
||||
if (!deps.localEnabled) { res.status(409).json({ error: "У этой панели нет своего VPN-сервера: выполните команду на машине" }); return; }
|
||||
try {
|
||||
const r = await deps.ctrlLocal("POST", `/api/monitor/${action}`);
|
||||
res.status(r.status === 404 ? 501 : r.status).json(
|
||||
r.status === 404 ? { error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" } : r.data);
|
||||
} catch { res.status(502).json({ error: "awg-ctrl недоступен" }); }
|
||||
};
|
||||
router.post("/install", ask("install"));
|
||||
router.post("/remove", ask("remove"));
|
||||
|
||||
router.get("/overview", async (req: Request, res: Response) => {
|
||||
const range = RANGES[String(req.query.range ?? "1h")];
|
||||
if (!range) { res.status(400).json({ error: "range: 1h, 6h, 24h, 7d или 30d" }); return; }
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - range.seconds;
|
||||
try {
|
||||
const keys = Object.keys(QUERIES) as Key[];
|
||||
const window = range.step > 120 ? `${range.step}s` : RATE_WINDOW;
|
||||
const got = await Promise.all(keys.map(k => rangeQuery(withWindow(QUERIES[k], window), start, end, range.step)));
|
||||
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
|
||||
const rated: Key[] = ["down", "up", "cpu"];
|
||||
const now = window === RATE_WINDOW ? null
|
||||
: new Map(await Promise.all(rated.map(async k => [k, await instantQuery(QUERIES[k], end)] as const)));
|
||||
// Вкладка показывает один сервер — тот, что выбран в сервер-баре (?server=<id>).
|
||||
const only = Number.isInteger(Number(req.query.server)) && req.query.server !== undefined ? Number(req.query.server) : null;
|
||||
const s = Object.fromEntries(keys.map((k, i) => [k,
|
||||
only === null ? got[i] : new Map([...got[i]].filter(([id]) => id === only))])) as Record<Key, Series>;
|
||||
|
||||
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
|
||||
const ids = new Set<number>();
|
||||
for (const k of keys) for (const id of s[k].keys()) ids.add(id);
|
||||
// Объём за весь период: increase() по тем же монотонным счётчикам интерфейса (сбросы счётчика он учитывает)
|
||||
const [usedDown, usedUp] = await Promise.all([
|
||||
instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[${range.seconds}s]))`, end),
|
||||
instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[${range.seconds}s]))`, end),
|
||||
]);
|
||||
const cur = (k: Key, id: number) => now?.has(k) ? (now.get(k)!.get(id) ?? null) : last(s[k].get(id));
|
||||
const servers = [...ids].sort((a, b) => a - b).map(id => ({
|
||||
id,
|
||||
name: (s.scrape_ok.get(id) ?? s.down.get(id) ?? s.online.get(id))?.name ?? String(id),
|
||||
ok: last(s.scrape_ok.get(id)) === 1,
|
||||
down: cur("down", id),
|
||||
up: cur("up", id),
|
||||
online: last(s.online.get(id)),
|
||||
heavy: last(s.heavy.get(id)),
|
||||
congested: last(s.congested.get(id)) === 1,
|
||||
cpu: cur("cpu", id),
|
||||
mem: last(s.mem.get(id)),
|
||||
cores: last(s.cores.get(id)),
|
||||
mem_total: last(s.mem_total.get(id)),
|
||||
used_down: usedDown.get(id) ?? null,
|
||||
used_up: usedUp.get(id) ?? null,
|
||||
}));
|
||||
|
||||
|
||||
res.json({
|
||||
range: req.query.range ?? "1h", step: range.step, from: start, to: end,
|
||||
servers,
|
||||
total: {
|
||||
down: total(s.down), up: total(s.up), online: total(s.online),
|
||||
heavy: total(s.heavy), drops: total(s.drops),
|
||||
cpu: total(s.cpu, "max"), mem: total(s.mem, "max"),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Prometheus стоит, но не ответил: график важнее ошибки, но причину оператору покажем
|
||||
res.status(502).json({ error: axios.isAxiosError(e) ? "Prometheus не отвечает" : (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return { router };
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
// 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.
|
||||
//
|
||||
// Несколько серверов под одной панелью: core (эта awg-ui) и nodes — VPN-серверы,
|
||||
// которые сами открывают к core исходящее WSS-соединение (awg-agent). Реестр нод
|
||||
// лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос
|
||||
// на своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
|
||||
//
|
||||
// server_id 0 — локальный awg-ctrl этой же машины (как было до нод); при
|
||||
// LOCAL_NODE=off его нет, core работает как чистая панель. Протокол и формат
|
||||
// join-строки — docs/node-protocol.md.
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import https from "https";
|
||||
import path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type Database from "better-sqlite3";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { spkiPin, publicKeyFromRaw } from "./sen";
|
||||
|
||||
export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
|
||||
|
||||
interface NodeRow {
|
||||
id: number; name: string; pub: string | null;
|
||||
join_hash: Buffer | null; join_expires: number | null;
|
||||
created_at: number; last_seen: number | null; version: string | null;
|
||||
}
|
||||
|
||||
interface Health {
|
||||
server: string; ip: string; gen: string; peers: number;
|
||||
awg_up: boolean; module: string; tools: string;
|
||||
}
|
||||
|
||||
interface Conn {
|
||||
ws: WebSocket; seq: number; alive: boolean;
|
||||
pending: Map<number, { resolve: (r: { status: number; data: any }) => void; timer: NodeJS.Timeout }>;
|
||||
}
|
||||
|
||||
const JOIN_TTL = 3600; // секунд жизни одноразового секрета
|
||||
const AUTH_TIMEOUT = 10_000; // на challenge–response после открытия сокета
|
||||
const HEALTH_EVERY = 15_000;
|
||||
const PING_EVERY = 20_000;
|
||||
const RPC_TIMEOUT = 15_000;
|
||||
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
|
||||
const NAME_RE = /^[^\r\n<>]{1,40}$/;
|
||||
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
|
||||
const VERSION_RE = /^[\w.+-]{1,32}$/;
|
||||
|
||||
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
|
||||
const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`);
|
||||
|
||||
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string; version: string }) {
|
||||
const { uidb, ctrlLocal } = deps;
|
||||
|
||||
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
|
||||
const LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off";
|
||||
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
|
||||
|
||||
// ── TLS: самоподписанный сертификат, pin которого зашит в join-строки ────
|
||||
// Живёт столько же, сколько core: пересоздать = переподключить все ноды.
|
||||
let tls: { key: Buffer; cert: Buffer } | null = null;
|
||||
let pin: Buffer | null = null;
|
||||
if (NODE_PORT) {
|
||||
const keyF = file(process.env.NODE_TLS_KEY_FILE, "node_tls.key");
|
||||
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
|
||||
try {
|
||||
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
|
||||
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
|
||||
const r = spawnSync("openssl", [
|
||||
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
|
||||
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=" + crypto.randomBytes(6).toString("hex"),
|
||||
], { stdio: "ignore" });
|
||||
if (r.status !== 0) throw new Error("openssl");
|
||||
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
|
||||
console.log("nodes: создан самоподписанный сертификат " + crtF);
|
||||
}
|
||||
tls = { key: fs.readFileSync(keyF), cert: fs.readFileSync(crtF) };
|
||||
pin = spkiPin(tls.cert.toString());
|
||||
} catch {
|
||||
console.warn("nodes: сертификат хаба недоступен (" + crtF + ") — приём нод отключён");
|
||||
tls = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Схема ──────────────────────────────────────────────────────────────
|
||||
uidb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`);
|
||||
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка
|
||||
// добавлена позже, у существующей таблицы её может не быть.
|
||||
if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||
uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT");
|
||||
const q = {
|
||||
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
|
||||
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
|
||||
insert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)"),
|
||||
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
|
||||
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
|
||||
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
|
||||
seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
|
||||
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
|
||||
};
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest();
|
||||
|
||||
// ── RPC к нодам ────────────────────────────────────────────────────────
|
||||
const conns = new Map<number, Conn>();
|
||||
const health = new Map<number, Health>();
|
||||
const OFFLINE = { status: 502, data: { error: "Сервер недоступен" } };
|
||||
|
||||
function rpc(id: number, method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
const c = conns.get(id);
|
||||
if (!c || c.ws.readyState !== WebSocket.OPEN) return Promise.resolve(OFFLINE);
|
||||
return new Promise(resolve => {
|
||||
const rid = ++c.seq;
|
||||
const timer = setTimeout(() => {
|
||||
c.pending.delete(rid);
|
||||
resolve({ status: 504, data: { error: "Сервер не ответил" } });
|
||||
}, urlPath.startsWith("/awg/") ? RPC_SLOW : RPC_TIMEOUT);
|
||||
c.pending.set(rid, { resolve, timer });
|
||||
c.ws.send(JSON.stringify({ t: "req", id: rid, method, path: urlPath, body }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Контроллер нужного сервера. 0 — локальный awg-ctrl, остальное — нода по WSS. */
|
||||
function ctrlFor(serverId: number): Ctrl {
|
||||
if (serverId === 0) {
|
||||
if (!LOCAL) return async () => ({ status: 404, data: { error: "Локальный сервер не настроен" } });
|
||||
return ctrlLocal;
|
||||
}
|
||||
if (!q.byId.get(serverId)) return async () => ({ status: 404, data: { error: "Сервер не найден" } });
|
||||
return (m, p, b) => rpc(serverId, m, p, b);
|
||||
}
|
||||
|
||||
const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined);
|
||||
|
||||
/** Есть ли сейчас связь с сервером (локальный считаем доступным всегда — до первого отказа awg-ctrl). */
|
||||
const isOnline = (id: number) => (id === 0 ? LOCAL : conns.has(id));
|
||||
|
||||
/** Все серверы реестра: локальный (если есть) и ноды. */
|
||||
const ids = (): number[] => [...(LOCAL ? [0] : []), ...(q.all.all() as NodeRow[]).map(n => n.id)];
|
||||
|
||||
// Подписчики на «сервер снова на связи» — подписка по этому сигналу догоняет пропущенное.
|
||||
const onlineHandlers: ((id: number) => void)[] = [];
|
||||
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
|
||||
|
||||
// Подписчики на «сервер удаляют». beforeDelete — проверка: может запретить удаление и не
|
||||
// меняет ui.db (запретить может и следующий подписчик). forcible — запрет снимается
|
||||
// подтверждением оператора (force): сервер не всё успел подтвердить, а не используется.
|
||||
// afterDelete — уборка своих ссылок на сервер, когда он уже удалён.
|
||||
type DeleteVeto = { error: string; forcible: boolean } | null;
|
||||
const deleteHandlers: ((id: number, force: boolean) => Promise<DeleteVeto>)[] = [];
|
||||
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { deleteHandlers.push(cb); };
|
||||
const afterHandlers: ((id: number) => void)[] = [];
|
||||
const afterDelete = (cb: (id: number) => void) => { afterHandlers.push(cb); };
|
||||
|
||||
/** Сервер по умолчанию: локальный, а без него — первая нода. */
|
||||
function defaultId(): number {
|
||||
if (LOCAL) return 0;
|
||||
return (q.all.all() as NodeRow[])[0]?.id ?? 0;
|
||||
}
|
||||
|
||||
async function refreshHealth(id: number) {
|
||||
const r = await (id === 0 ? ctrlLocal("GET", "/health") : rpc(id, "GET", "/health")).catch(() => null);
|
||||
const d = r?.data;
|
||||
if (d && typeof d === "object" && typeof d.server === "string") {
|
||||
health.set(id, {
|
||||
server: d.server, ip: d.ip ?? "", gen: d.gen ?? "2", peers: d.awg?.peers ?? 0,
|
||||
awg_up: d.awg?.status === "ok", module: d.awg?.module ?? "", tools: d.awg?.tools ?? "",
|
||||
});
|
||||
} else health.delete(id);
|
||||
}
|
||||
|
||||
// ── Хаб: приём соединений от нод ───────────────────────────────────────
|
||||
const failHits = new Map<string, { n: number; reset: number }>();
|
||||
const failed = (ip: string) => {
|
||||
const t = Date.now(), e = failHits.get(ip);
|
||||
if (!e || e.reset < t) failHits.set(ip, { n: 1, reset: t + 15 * 60_000 });
|
||||
else e.n++;
|
||||
};
|
||||
const blocked = (ip: string) => {
|
||||
const e = failHits.get(ip);
|
||||
return !!e && e.reset > Date.now() && e.n >= 20;
|
||||
};
|
||||
|
||||
function dropConn(id: number, c: Conn) {
|
||||
for (const p of c.pending.values()) { clearTimeout(p.timer); p.resolve(OFFLINE); }
|
||||
c.pending.clear();
|
||||
if (conns.get(id) === c) { conns.delete(id); health.delete(id); }
|
||||
}
|
||||
|
||||
function onConnection(ws: WebSocket, req: { socket: { remoteAddress?: string } }) {
|
||||
const ip = req.socket.remoteAddress ?? "unknown";
|
||||
if (blocked(ip)) { ws.close(4029, "rate_limited"); return; }
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString("base64url");
|
||||
let conn: Conn | null = null;
|
||||
let nodeId = 0;
|
||||
const authTimer = setTimeout(() => ws.close(4001, "auth_timeout"), AUTH_TIMEOUT);
|
||||
const deny = () => { failed(ip); ws.close(4003, "unauthorized"); };
|
||||
|
||||
function authenticate(m: any) {
|
||||
if (m?.t !== "auth" || !Number.isInteger(m.node_id) || typeof m.sig !== "string") return deny();
|
||||
const row = q.byId.get(m.node_id) as NodeRow | undefined;
|
||||
if (!row) return deny();
|
||||
|
||||
let pubB64: string | null = row.pub;
|
||||
const joining = pubB64 === null;
|
||||
if (joining) {
|
||||
const j = m.join;
|
||||
if (!row.join_hash || !row.join_expires || row.join_expires < now()
|
||||
|| typeof j?.secret !== "string" || typeof j?.pub !== "string") return deny();
|
||||
const h = sha(Buffer.from(j.secret, "base64url"));
|
||||
if (h.length !== row.join_hash.length || !crypto.timingSafeEqual(h, row.join_hash)) return deny();
|
||||
pubB64 = j.pub;
|
||||
}
|
||||
try {
|
||||
const key = publicKeyFromRaw(Buffer.from(pubB64 as string, "base64url"));
|
||||
if (!crypto.verify(null, authMsg(nonce, row.id), key, Buffer.from(m.sig, "base64url"))) return deny();
|
||||
} catch { return deny(); }
|
||||
|
||||
clearTimeout(authTimer);
|
||||
if (joining) q.bind.run(pubB64 as string, row.id);
|
||||
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
|
||||
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
|
||||
q.seen.run(now(), version, row.id);
|
||||
|
||||
const old = conns.get(row.id);
|
||||
conn = { ws, seq: 0, alive: true, pending: new Map() };
|
||||
conns.set(row.id, conn);
|
||||
nodeId = row.id;
|
||||
if (old) { dropConn(row.id, old); conns.set(row.id, conn); old.ws.close(4000, "replaced"); }
|
||||
|
||||
ws.send(JSON.stringify({ t: "ready" }));
|
||||
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
|
||||
void refreshHealth(row.id);
|
||||
for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } }
|
||||
}
|
||||
|
||||
ws.on("message", raw => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(raw.toString()); } catch { ws.close(4002, "bad_frame"); return; }
|
||||
if (!conn) { authenticate(m); return; }
|
||||
if (m?.t === "res" && Number.isInteger(m.id)) {
|
||||
const p = conn.pending.get(m.id);
|
||||
if (!p) return;
|
||||
conn.pending.delete(m.id);
|
||||
clearTimeout(p.timer);
|
||||
p.resolve({ status: Number(m.status) || 502, data: m.data });
|
||||
}
|
||||
});
|
||||
ws.on("pong", () => { if (conn) conn.alive = true; });
|
||||
ws.on("close", () => {
|
||||
clearTimeout(authTimer);
|
||||
if (conn) { dropConn(nodeId, conn); console.log(`nodes: #${nodeId} отключена`); }
|
||||
});
|
||||
ws.on("error", () => { /* close придёт следом */ });
|
||||
|
||||
ws.send(JSON.stringify({ t: "hello", nonce }));
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (LOCAL) {
|
||||
void refreshHealth(0);
|
||||
for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } }
|
||||
}
|
||||
if (!NODE_PORT || !tls) return;
|
||||
const server = https.createServer(tls);
|
||||
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
|
||||
wss.on("connection", onConnection);
|
||||
server.listen(NODE_PORT, () => console.log(`nodes: hub on :${NODE_PORT} (wss)`));
|
||||
|
||||
setInterval(() => {
|
||||
for (const [id, c] of conns) {
|
||||
if (!c.alive) { c.ws.terminate(); dropConn(id, c); continue; }
|
||||
c.alive = false;
|
||||
try { c.ws.ping(); } catch { /* закроется само */ }
|
||||
}
|
||||
}, PING_EVERY).unref();
|
||||
setInterval(() => { for (const id of conns.keys()) void refreshHealth(id); }, HEALTH_EVERY).unref();
|
||||
}
|
||||
|
||||
// ── Роуты панели (монтируются в server.ts за JWT) ──────────────────────
|
||||
const joinString = (host: string, id: number, secret: Buffer) => "awgjoin://" + Buffer.from(JSON.stringify({
|
||||
v: 1, h: host, p: NODE_PORT, pin: (pin as Buffer).toString("base64url"), n: id, s: secret.toString("base64url"),
|
||||
})).toString("base64url");
|
||||
|
||||
const hostOf = (req: Request): string => {
|
||||
const given = (req.body as { host?: unknown } | undefined)?.host;
|
||||
if (typeof given === "string" && HOST_RE.test(given)) return given;
|
||||
return (req.headers.host ?? "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
||||
};
|
||||
const idOf = (req: Request) => Number(req.params.id);
|
||||
const validId = (n: number) => Number.isInteger(n) && n >= 1;
|
||||
const hubReady = () => NODE_PORT > 0 && tls !== null && pin !== null;
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и для метрик.
|
||||
const list = () => {
|
||||
const nodes: { id: number; name: string; local: boolean; online: boolean; pending: boolean; last_seen: number | null; version: string | null; health: Health | null }[] = [];
|
||||
if (LOCAL) {
|
||||
const h = health.get(0) ?? null;
|
||||
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
|
||||
}
|
||||
for (const n of q.all.all() as NodeRow[]) {
|
||||
nodes.push({
|
||||
id: n.id, name: n.name, local: false, online: conns.has(n.id),
|
||||
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
if (LOCAL) await refreshHealth(0);
|
||||
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes: list() });
|
||||
});
|
||||
|
||||
router.post("/", (req: Request, res: Response) => {
|
||||
if (!hubReady()) { res.status(503).json({ error: "Приём нод не настроен (NODE_PORT / сертификат)" }); return; }
|
||||
const name = (req.body as { name?: unknown } | undefined)?.name;
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) { res.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" }); return; }
|
||||
const host = hostOf(req);
|
||||
if (!HOST_RE.test(host)) { res.status(400).json({ error: "Не удалось определить адрес core" }); return; }
|
||||
const secret = crypto.randomBytes(16);
|
||||
const id = Number(q.insert.run(name, sha(secret), now() + JOIN_TTL, now()).lastInsertRowid);
|
||||
res.status(201).json({ id, name, join: joinString(host, id, secret), expires_in: JOIN_TTL });
|
||||
});
|
||||
|
||||
// Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли).
|
||||
router.post("/:id/join", (req: Request, res: Response) => {
|
||||
if (!hubReady()) { res.status(503).json({ error: "Приём нод не настроен (NODE_PORT / сертификат)" }); return; }
|
||||
const id = idOf(req);
|
||||
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
||||
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
if (n.pub !== null) { res.status(409).json({ error: "Нода уже подключена" }); return; }
|
||||
const host = hostOf(req);
|
||||
if (!HOST_RE.test(host)) { res.status(400).json({ error: "Не удалось определить адрес core" }); return; }
|
||||
const secret = crypto.randomBytes(16);
|
||||
q.rejoin.run(sha(secret), now() + JOIN_TTL, id);
|
||||
res.json({ id, name: n.name, join: joinString(host, id, secret), expires_in: JOIN_TTL });
|
||||
});
|
||||
|
||||
router.patch("/:id", (req: Request, res: Response) => {
|
||||
const id = idOf(req);
|
||||
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
||||
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const name = (req.body as { name?: unknown } | undefined)?.name;
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) { res.status(400).json({ error: "Неверное имя" }); return; }
|
||||
q.rename.run(name, id);
|
||||
res.json({ id, name });
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
const id = idOf(req);
|
||||
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
||||
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
// Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete).
|
||||
const force = req.query.force === "1";
|
||||
for (const cb of deleteHandlers) {
|
||||
let veto: DeleteVeto;
|
||||
try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
|
||||
if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; }
|
||||
}
|
||||
if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const c = conns.get(id);
|
||||
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
|
||||
q.delete.run(id);
|
||||
// Хвосты подписки для этого сервера больше некому доделать.
|
||||
for (const t of ["peer_tombstones", "server_profiles"]) {
|
||||
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ }
|
||||
}
|
||||
for (const cb of afterHandlers) { try { cb(id); } catch (e) { console.warn(`nodes: уборка после удаления #${id}: ${(e as Error).message}`); } }
|
||||
res.json({ success: true, id });
|
||||
});
|
||||
|
||||
return { router, ctrlFor, exists, isOnline, ids, list, onOnline, beforeDelete, afterDelete, defaultId, start };
|
||||
}
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.1.4+1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,20 +15,22 @@
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"express": "^4.19.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
// 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.
|
||||
//
|
||||
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1.
|
||||
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент
|
||||
// реализуется по ней, поэтому любое изменение здесь — изменение протокола.
|
||||
import crypto from "crypto";
|
||||
|
||||
export const SEN_PREFIX = "sen://";
|
||||
export const SEN_VERSION = 1;
|
||||
export const FLAG_TLS = 0x01;
|
||||
|
||||
const ADDR_V4 = 0x04;
|
||||
const ADDR_V6 = 0x06;
|
||||
const ADDR_DOMAIN = 0x44;
|
||||
|
||||
export class SenLinkError extends Error {}
|
||||
|
||||
export interface SenAddr { host: string; port: number }
|
||||
|
||||
export interface SenLink {
|
||||
tls: boolean;
|
||||
addrs: SenAddr[];
|
||||
secret: Buffer; // 16 байт
|
||||
signPub: Buffer; // 32 байта, Ed25519
|
||||
tlsPin?: Buffer; // 32 байта, только при tls
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ── CRC32 (IEEE) ───────────────────────────────────────────────────────────
|
||||
// zlib.crc32 появился только в Node 20.15, а целимся в 20.x целиком.
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
export function crc32(buf: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// ── Адреса ─────────────────────────────────────────────────────────────────
|
||||
function ipv6ToBytes(host: string): Buffer | null {
|
||||
const parts = host.split("::");
|
||||
if (parts.length > 2) return null;
|
||||
const head = parts[0] ? parts[0].split(":") : [];
|
||||
const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (parts.length === 1 ? head.length !== 8 : missing < 1) return null;
|
||||
const groups = [...head, ...Array(parts.length === 2 ? missing : 0).fill("0"), ...tail];
|
||||
if (groups.length !== 8) return null;
|
||||
const out = Buffer.alloc(16);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
|
||||
out.writeUInt16BE(parseInt(groups[i], 16), i * 2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToIpv6(b: Buffer): string {
|
||||
const g: string[] = [];
|
||||
for (let i = 0; i < 16; i += 2) g.push(b.readUInt16BE(i).toString(16));
|
||||
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
|
||||
let bestStart = -1, bestLen = 0;
|
||||
for (let i = 0; i < 8;) {
|
||||
if (g[i] !== "0") { i++; continue; }
|
||||
let j = i;
|
||||
while (j < 8 && g[j] === "0") j++;
|
||||
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
|
||||
i = j;
|
||||
}
|
||||
if (bestLen < 2) return g.join(":");
|
||||
return g.slice(0, bestStart).join(":") + "::" + g.slice(bestStart + bestLen).join(":");
|
||||
}
|
||||
|
||||
function encodeAddr({ host, port }: SenAddr): Buffer {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SenLinkError(`Неверный порт: ${port}`);
|
||||
const p = Buffer.alloc(2);
|
||||
p.writeUInt16BE(port);
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
||||
const o = host.split(".").map(Number);
|
||||
if (o.some(n => n > 255)) throw new SenLinkError(`Неверный IPv4: ${host}`);
|
||||
return Buffer.concat([Buffer.from([ADDR_V4, ...o]), p]);
|
||||
}
|
||||
if (host.includes(":")) {
|
||||
const b = ipv6ToBytes(host);
|
||||
if (!b) throw new SenLinkError(`Неверный IPv6: ${host}`);
|
||||
return Buffer.concat([Buffer.from([ADDR_V6]), b, p]);
|
||||
}
|
||||
const h = Buffer.from(host, "utf8");
|
||||
if (h.length < 1 || h.length > 253 || !/^[A-Za-z0-9.-]+$/.test(host))
|
||||
throw new SenLinkError(`Неверный домен: ${host}`);
|
||||
return Buffer.concat([Buffer.from([ADDR_DOMAIN, h.length]), h, p]);
|
||||
}
|
||||
|
||||
// ── Ссылка ─────────────────────────────────────────────────────────────────
|
||||
export function encodeSenLink(l: SenLink): string {
|
||||
if (l.addrs.length < 1 || l.addrs.length > 3) throw new SenLinkError("Адресов должно быть от 1 до 3");
|
||||
if (l.secret.length !== 16) throw new SenLinkError("secret: 16 байт");
|
||||
if (l.signPub.length !== 32) throw new SenLinkError("signPub: 32 байта");
|
||||
if (l.tls && l.tlsPin?.length !== 32) throw new SenLinkError("tlsPin: 32 байта при TLS");
|
||||
const name = Buffer.from(l.name, "utf8");
|
||||
if (name.length > 64) throw new SenLinkError("Имя: до 64 байт");
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([SEN_VERSION, l.tls ? FLAG_TLS : 0, l.addrs.length]),
|
||||
...l.addrs.map(encodeAddr),
|
||||
l.secret,
|
||||
l.signPub,
|
||||
...(l.tls ? [l.tlsPin as Buffer] : []),
|
||||
Buffer.from([name.length]),
|
||||
name,
|
||||
]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return SEN_PREFIX + Buffer.concat([body, crc]).toString("base64url");
|
||||
}
|
||||
|
||||
export function decodeSenLink(link: string): SenLink {
|
||||
const t = link.trim();
|
||||
if (!t.toLowerCase().startsWith(SEN_PREFIX)) throw new SenLinkError("Ссылка должна начинаться с sen://");
|
||||
const payload = t.slice(SEN_PREFIX.length).replace(/\s+/g, "");
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(payload)) throw new SenLinkError("Ссылка повреждена: недопустимые символы");
|
||||
const raw = Buffer.from(payload, "base64url");
|
||||
if (raw.length < 3 + 4) throw new SenLinkError("Ссылка слишком короткая");
|
||||
|
||||
const body = raw.subarray(0, raw.length - 4);
|
||||
if (raw.readUInt32BE(raw.length - 4) !== crc32(body)) throw new SenLinkError("Ссылка повреждена: не сошлась контрольная сумма");
|
||||
|
||||
let off = 0;
|
||||
const need = (n: number) => { if (off + n > body.length) throw new SenLinkError("Ссылка обрезана"); };
|
||||
const take = (n: number) => { need(n); const s = body.subarray(off, off + n); off += n; return s; };
|
||||
|
||||
const version = take(1)[0];
|
||||
if (version !== SEN_VERSION) throw new SenLinkError(`Неизвестная версия ссылки: ${version}`);
|
||||
const flags = take(1)[0];
|
||||
if (flags & ~FLAG_TLS) throw new SenLinkError("Неизвестные флаги ссылки");
|
||||
const n = take(1)[0];
|
||||
if (n < 1 || n > 3) throw new SenLinkError("Неверное число адресов");
|
||||
|
||||
const addrs: SenAddr[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const type = take(1)[0];
|
||||
let host: string;
|
||||
if (type === ADDR_V4) host = [...take(4)].join(".");
|
||||
else if (type === ADDR_V6) host = bytesToIpv6(Buffer.from(take(16)));
|
||||
else if (type === ADDR_DOMAIN) {
|
||||
const len = take(1)[0];
|
||||
if (len < 1) throw new SenLinkError("Пустой домен");
|
||||
host = Buffer.from(take(len)).toString("utf8");
|
||||
if (!/^[A-Za-z0-9.-]+$/.test(host)) throw new SenLinkError("Неверный домен");
|
||||
} else throw new SenLinkError(`Неизвестный тип адреса: ${type}`);
|
||||
const port = Buffer.from(take(2)).readUInt16BE(0);
|
||||
if (port === 0) throw new SenLinkError("Порт 0");
|
||||
addrs.push({ host, port });
|
||||
}
|
||||
|
||||
const secret = Buffer.from(take(16));
|
||||
const signPub = Buffer.from(take(32));
|
||||
const tls = (flags & FLAG_TLS) !== 0;
|
||||
const tlsPin = tls ? Buffer.from(take(32)) : undefined;
|
||||
const nameLen = take(1)[0];
|
||||
if (nameLen > 64) throw new SenLinkError("Имя длиннее 64 байт");
|
||||
const name = Buffer.from(take(nameLen)).toString("utf8");
|
||||
if (off !== body.length) throw new SenLinkError("Лишние данные в конце ссылки");
|
||||
|
||||
return { tls, addrs, secret, signPub, tlsPin, name };
|
||||
}
|
||||
|
||||
// ── Ключи Ed25519 в «сыром» виде (32 байта) ────────────────────────────────
|
||||
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
|
||||
export function rawPublicKey(key: crypto.KeyObject): Buffer {
|
||||
const der = key.export({ type: "spki", format: "der" }) as Buffer;
|
||||
return der.subarray(der.length - 32);
|
||||
}
|
||||
|
||||
export function publicKeyFromRaw(raw: Buffer): crypto.KeyObject {
|
||||
if (raw.length !== 32) throw new SenLinkError("Публичный ключ Ed25519: 32 байта");
|
||||
return crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" });
|
||||
}
|
||||
|
||||
/** SHA-256 от SPKI DER сертификата — то, что кладётся в ссылку как pin. */
|
||||
export function spkiPin(certPem: string): Buffer {
|
||||
const spki = new crypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
||||
return crypto.createHash("sha256").update(spki).digest();
|
||||
}
|
||||
|
||||
// ── Подписи протокола ──────────────────────────────────────────────────────
|
||||
// Ответ: {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
|
||||
// UTF-8 байтами строки body. body — именно строка, а не вложенный объект,
|
||||
// чтобы клиенту не нужна была каноническая сериализация JSON.
|
||||
export function signResponse(payload: unknown, signKey: crypto.KeyObject): { body: string; sig: string } {
|
||||
const body = JSON.stringify(payload);
|
||||
return { body, sig: crypto.sign(null, Buffer.from(body, "utf8"), signKey).toString("base64url") };
|
||||
}
|
||||
|
||||
export function verifyResponse(res: { body: string; sig: string }, signPub: Buffer): boolean {
|
||||
try {
|
||||
return crypto.verify(null, Buffer.from(res.body, "utf8"), publicKeyFromRaw(signPub), Buffer.from(res.sig, "base64url"));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Запрос: подпись ключом устройства (auth_pub) над строкой
|
||||
// METHOD \n /path \n ts \n hex(sha256(тело запроса))
|
||||
// Тело пустое → sha256 пустой строки. path — без query.
|
||||
export function requestSigString(method: string, urlPath: string, ts: number, body: Buffer | string): string {
|
||||
const h = crypto.createHash("sha256").update(body).digest("hex");
|
||||
return `${method.toUpperCase()}\n${urlPath}\n${ts}\n${h}`;
|
||||
}
|
||||
|
||||
export function signRequest(
|
||||
method: string, urlPath: string, ts: number, body: Buffer | string, authKey: crypto.KeyObject,
|
||||
): string {
|
||||
return crypto.sign(null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"), authKey).toString("base64url");
|
||||
}
|
||||
|
||||
export function verifyRequest(
|
||||
method: string, urlPath: string, ts: number, body: Buffer | string, sig: string, authPub: Buffer,
|
||||
): boolean {
|
||||
try {
|
||||
return crypto.verify(
|
||||
null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"),
|
||||
publicKeyFromRaw(authPub), Buffer.from(sig, "base64url"),
|
||||
);
|
||||
} catch { return false; }
|
||||
}
|
||||
+182
-34
@@ -7,6 +7,10 @@ import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import Database from "better-sqlite3";
|
||||
import { createSub } from "./sub";
|
||||
import { createNodes } from "./nodes";
|
||||
import { createMetrics } from "./metrics";
|
||||
import { createMonitor } from "./monitor";
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
@@ -16,6 +20,20 @@ const UI_PASS = process.env.UI_PASS ?? "";
|
||||
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
|
||||
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
|
||||
|
||||
// Имя панели, канал и версия. В проде их прокидывает CLI (он читает .env в
|
||||
// корне проекта и cli.env); при самостоятельном запуске `npm run server`
|
||||
// читаем тот же .env сами, чтобы дев-режим показывал то же, что прод.
|
||||
function envFromFile(file: string, key: string): string {
|
||||
try {
|
||||
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
|
||||
return m ? m[1].trim() : "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
const ROOT_ENV = path.join(__dirname, "..", ".env");
|
||||
const BRAND = process.env.BRAND || envFromFile(ROOT_ENV, "BRAND") || "Forgetting";
|
||||
const CHANNEL = process.env.CHANNEL || envFromFile(ROOT_ENV, "CHANNEL") || "Beta";
|
||||
const VERSION = process.env.VERSION || envFromFile(ROOT_ENV, "VERSION") || "";
|
||||
|
||||
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE
|
||||
?? path.join(__dirname, "internal_auth_private.key");
|
||||
let internalAuthKey: crypto.KeyObject | null = null;
|
||||
@@ -37,6 +55,9 @@ function mintInternalToken(): string {
|
||||
|
||||
const revoked = new Set<string>();
|
||||
|
||||
// ── Ключи внешнего API (/api/v1) ───────────────────────────────────────────
|
||||
// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое
|
||||
// значение ключа не хранится — только SHA-256 и префикс для показа в панели.
|
||||
interface ApiKeyRow {
|
||||
id: number; label: string; key_hash: string; prefix: string;
|
||||
server_id: number; created_at: number; last_used: number | null;
|
||||
@@ -54,17 +75,56 @@ uidb.exec(`
|
||||
server_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER
|
||||
)
|
||||
);
|
||||
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
|
||||
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
|
||||
-- всегда входит в набор.
|
||||
CREATE TABLE IF NOT EXISTS api_key_servers (
|
||||
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
server_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (key_id, server_id)
|
||||
);
|
||||
-- Ключи со времён одного сервера: набор — их server_id.
|
||||
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
|
||||
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
|
||||
`);
|
||||
|
||||
const keyStmts = {
|
||||
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
|
||||
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
|
||||
byId: uidb.prepare<[number]>("SELECT * FROM api_keys WHERE id = ?"),
|
||||
setDefault: uidb.prepare<[number, number]>("UPDATE api_keys SET server_id = ? WHERE id = ?"),
|
||||
serversOf: uidb.prepare<[number]>("SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id"),
|
||||
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)"),
|
||||
serversClear: uidb.prepare<[number]>("DELETE FROM api_key_servers WHERE key_id = ?"),
|
||||
keysOnServer: uidb.prepare<[number]>(
|
||||
"SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?"),
|
||||
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
|
||||
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
|
||||
};
|
||||
|
||||
const keyServers = (id: number) => (keyStmts.serversOf.all(id) as { server_id: number }[]).map(r => r.server_id);
|
||||
|
||||
// Набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в наборе, иначе —
|
||||
// наименьший из набора.
|
||||
function setKeyServers(id: number, ids: number[]) {
|
||||
const row = keyStmts.byId.get(id) as ApiKeyRow;
|
||||
uidb.transaction(() => {
|
||||
keyStmts.serversClear.run(id);
|
||||
for (const sid of ids) keyStmts.serverAdd.run(id, sid);
|
||||
if (!ids.includes(row.server_id)) keyStmts.setDefault.run(ids[0], id);
|
||||
})();
|
||||
}
|
||||
|
||||
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
|
||||
function parseKeyServers(v: unknown): number[] | string {
|
||||
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
|
||||
const ids = [...new Set(v)];
|
||||
if (!ids.every(id => Number.isInteger(id) && nodes.exists(id as number))) return "Такого сервера нет";
|
||||
return (ids as number[]).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function hashKey(key: string): string {
|
||||
return crypto.createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
@@ -146,49 +206,73 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
|
||||
res.json({ keys: keyStmts.list.all() });
|
||||
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
|
||||
// Экран логина обходится без вордмарка.
|
||||
app.get("/ui/brand", requireAuth, (_req: Request, res: Response) => {
|
||||
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
|
||||
});
|
||||
|
||||
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
|
||||
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
|
||||
res.json({ keys: (keyStmts.list.all() as ApiKeyRow[]).map(k => ({ ...k, servers: keyServers(k.id) })) });
|
||||
});
|
||||
|
||||
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
|
||||
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
|
||||
const { label, server_id } = req.body as { label?: string; server_id?: number };
|
||||
const { label, server_id, servers } = req.body as { label?: string; server_id?: number; servers?: unknown };
|
||||
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
|
||||
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
|
||||
}
|
||||
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
|
||||
// список серверов — валидировать его против реального реестра серверов.
|
||||
const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]);
|
||||
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
|
||||
const sid = server_id !== undefined && ids.includes(Number(server_id)) ? Number(server_id) : ids[0];
|
||||
const { key, hash, prefix } = genApiKey();
|
||||
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
|
||||
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
|
||||
let id = 0;
|
||||
uidb.transaction(() => {
|
||||
id = Number(keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)).lastInsertRowid);
|
||||
for (const s of ids) keyStmts.serverAdd.run(id, s);
|
||||
})();
|
||||
// Открытое значение отдаётся один раз — дальше в БД только хэш.
|
||||
res.status(201).json({ id, label, prefix, key, server_id: sid, servers: ids });
|
||||
});
|
||||
|
||||
// Сменить набор серверов, не перевыпуская ключ.
|
||||
app.patch("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || !keyStmts.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const ids = parseKeyServers((req.body as { servers?: unknown } | undefined)?.servers);
|
||||
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
|
||||
setKeyServers(id, ids);
|
||||
const row = keyStmts.byId.get(id) as ApiKeyRow;
|
||||
res.json({ id, server_id: row.server_id, servers: ids });
|
||||
});
|
||||
|
||||
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
|
||||
keyStmts.delete.run(id);
|
||||
uidb.transaction(() => { keyStmts.serversClear.run(id); keyStmts.delete.run(id); })();
|
||||
res.json({ success: true, id });
|
||||
});
|
||||
|
||||
// Сервер, к которому относится запрос панели: заголовок X-Server-Id (0 — локальный
|
||||
// awg-ctrl, остальное — нода). Без заголовка — сервер по умолчанию.
|
||||
function serverIdOf(req: Request): number {
|
||||
const raw = req.headers["x-server-id"];
|
||||
if (raw === undefined) return nodes.defaultId();
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 0 ? n : nodes.defaultId();
|
||||
}
|
||||
|
||||
async function proxy(req: Request, res: Response) {
|
||||
try {
|
||||
const r = await axios({
|
||||
method: req.method,
|
||||
url: CTRL + req.originalUrl,
|
||||
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
|
||||
data: req.body,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
const r = await nodes.ctrlFor(serverIdOf(req))(req.method, req.originalUrl, req.body);
|
||||
res.status(r.status).json(r.data);
|
||||
} catch {
|
||||
res.status(502).json({ error: "awg-ctrl недоступен" });
|
||||
}
|
||||
}
|
||||
|
||||
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий
|
||||
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
|
||||
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
|
||||
|
||||
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
|
||||
interface ExtRequest extends Request { apiKey?: ApiKeyRow; servers?: number[]; serverId?: number; }
|
||||
|
||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const key = (req.headers["x-api-key"] as string) ?? "";
|
||||
@@ -197,6 +281,18 @@ function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
|
||||
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
|
||||
(req as ExtRequest).apiKey = row;
|
||||
(req as ExtRequest).servers = keyServers(row.id);
|
||||
next();
|
||||
}
|
||||
|
||||
// Сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — сервер ключа по
|
||||
// умолчанию. Сервер вне набора — как несуществующий.
|
||||
function pickKeyServer(req: Request, res: Response, next: NextFunction) {
|
||||
const r = req as ExtRequest;
|
||||
const raw = req.headers["x-server-id"];
|
||||
const id = raw === undefined ? r.apiKey!.server_id : Number(raw);
|
||||
if (!Number.isInteger(id) || !r.servers!.includes(id)) { res.status(404).json({ error: "Сервер не найден" }); return; }
|
||||
r.serverId = id;
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -217,33 +313,83 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
|
||||
const ext = express.Router();
|
||||
ext.use(requireApiKey);
|
||||
|
||||
ext.post("/users", async (req: Request, res: Response) => {
|
||||
// vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer.
|
||||
ext.use("/users", pickKeyServer);
|
||||
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0);
|
||||
|
||||
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
|
||||
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
|
||||
// тот же, что у proxy() в такой ситуации.
|
||||
type Handler = (req: Request, res: Response) => Promise<void>;
|
||||
const wrap = (fn: Handler) => (req: Request, res: Response) => {
|
||||
fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); });
|
||||
};
|
||||
|
||||
ext.post("/users", wrap(async (req, res) => {
|
||||
const { name } = (req.body ?? {}) as { name?: string };
|
||||
const r = await ctrl("POST", "/api/users", { name });
|
||||
const r = await ctrlOf(req)("POST", "/api/users", { name });
|
||||
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||
const u = r.data as ExtUser;
|
||||
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
|
||||
});
|
||||
}));
|
||||
|
||||
ext.get("/users", async (_req: Request, res: Response) => {
|
||||
const r = await ctrl("GET", "/api/users/stats");
|
||||
ext.get("/users", wrap(async (req, res) => {
|
||||
const r = await ctrlOf(req)("GET", "/api/users/stats");
|
||||
res.status(r.status).json(r.data);
|
||||
});
|
||||
}));
|
||||
|
||||
ext.get("/users/:name", async (req: Request, res: Response) => {
|
||||
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||
ext.get("/users/:name", wrap(async (req, res) => {
|
||||
const r = await ctrlOf(req)("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||
const u = r.data as ExtUser;
|
||||
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
|
||||
});
|
||||
}));
|
||||
|
||||
ext.delete("/users/:name", async (req: Request, res: Response) => {
|
||||
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||
ext.delete("/users/:name", wrap(async (req, res) => {
|
||||
const r = await ctrlOf(req)("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||
res.status(r.status).json(r.data);
|
||||
});
|
||||
}));
|
||||
|
||||
// ВАЖНО: /api/v1 монтируется ДО JWT-прокси на /api — иначе прокси перехватит
|
||||
// его и потребует JWT вместо ключа.
|
||||
app.use("/api/v1", ext);
|
||||
|
||||
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
||||
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
||||
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
|
||||
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
|
||||
app.use("/ui/nodes", requireAuth, nodes.router);
|
||||
|
||||
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
||||
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
|
||||
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
||||
app.use("/ui/devices", requireAuth, sub.devices);
|
||||
|
||||
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API), токеном
|
||||
// управляет панель. Регистрируется до SPA-фолбэка внизу, иначе его перехватит `*`.
|
||||
const metrics = createMetrics({ uidb, version: VERSION, servers: nodes });
|
||||
app.use("/ui/metrics-token", requireAuth, metrics.router);
|
||||
app.get("/metrics", metrics.requireToken, metrics.handler);
|
||||
// Монитор: встроенный Prometheus и графики во вкладке. Ставит его не панель, а awg-ctrl от root.
|
||||
const monitor = createMonitor({ ctrlLocal: ctrl, localEnabled: (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off" });
|
||||
app.use("/ui/monitor", requireAuth, monitor.router);
|
||||
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
|
||||
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
|
||||
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
|
||||
|
||||
// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать
|
||||
// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён.
|
||||
type KeyOnServer = { id: number; label: string; server_id: number; n: number };
|
||||
nodes.beforeDelete(async id => {
|
||||
const only = (keyStmts.keysOnServer.all(id) as KeyOnServer[]).filter(k => k.n === 1).map(k => k.label);
|
||||
return only.length
|
||||
? { error: `Это единственный сервер ключей API: ${only.join(", ")} — сначала удалите их или добавьте им другой сервер`, forcible: false }
|
||||
: null;
|
||||
});
|
||||
nodes.afterDelete(id => {
|
||||
for (const k of keyStmts.keysOnServer.all(id) as KeyOnServer[]) setKeyServers(k.id, keyServers(k.id).filter(s => s !== id));
|
||||
});
|
||||
|
||||
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
|
||||
|
||||
app.use(express.static(path.join(__dirname, "dist")));
|
||||
@@ -252,4 +398,6 @@ app.get("*", (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(__dirname, "dist", "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
|
||||
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
|
||||
nodes.start();
|
||||
sub.start();
|
||||
+588
-244
File diff suppressed because it is too large
Load Diff
+315
-66
@@ -3,27 +3,43 @@
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import './tokens.css';
|
||||
import './App.css';
|
||||
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||
import {
|
||||
TOKEN_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId, serversLabel, openTab, MIN_NODE_VERSION,
|
||||
type NodeInfo, type Brand,
|
||||
} from './lib/shared';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh, IcoChevronDown } from './components/icons';
|
||||
import ServerBar from './components/ServerBar';
|
||||
import { TABS } from './tabs';
|
||||
import DrumTitle from './components/DrumTitle';
|
||||
import { useTheme, setThemeMode } from './lib/theme';
|
||||
import AddServerModal from './components/AddServerModal';
|
||||
import { TABS, TABS_BY_GROUP } from './tabs';
|
||||
|
||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||
// из реестра TABS — App про конкретные вкладки ничего не знает.
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [hubEnabled, setHubEnabled] = useState(false);
|
||||
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [serversOpen, setServersOpen] = useState(false); // выпадающий список серверов
|
||||
const serverSelectRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||
// Встроенный Prometheus: null — ещё не знаем; fresh — только что поставили (пункт «Монитор» въезжает в меню);
|
||||
// leaving — только что удалили (пункт ещё в меню, пока доигрывает уход)
|
||||
const [prom, setProm] = useState<{ ready: boolean; fresh: boolean; leaving: boolean } | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
||||
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
|
||||
const { theme } = useTheme();
|
||||
const touchStartX = useRef(0);
|
||||
const touchCurrentX = useRef(0);
|
||||
|
||||
@@ -32,14 +48,35 @@ export default function App() {
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}, []);
|
||||
|
||||
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
|
||||
// вкладок (юзеры, ключи) грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2' });
|
||||
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
||||
// Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок
|
||||
// (setServerId — до смены state, чтобы вкладка с новым key читала уже с ним).
|
||||
const pickServer = useCallback((id: number | null) => {
|
||||
setServerId(id);
|
||||
setServerIdState(id);
|
||||
try { if (id !== null) localStorage.setItem(SERVER_KEY, String(id)); } catch { /* не критично */ }
|
||||
}, []);
|
||||
|
||||
// Список серверов (core + ноды) с их состоянием. Если выбранного больше нет —
|
||||
// берём сохранённый, иначе первый.
|
||||
const loadNodes = useCallback(async (tok: string) => {
|
||||
const { data } = await axios.get('/ui/nodes', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
const list: NodeInfo[] = data.nodes ?? [];
|
||||
setNodes(list);
|
||||
setHubEnabled(!!data.hub);
|
||||
setPanelVersion(data.version ?? null);
|
||||
const cur = getServerId();
|
||||
if (cur !== null && list.some(n => n.id === cur)) return;
|
||||
let saved: number | null = null;
|
||||
try { const raw = localStorage.getItem(SERVER_KEY); saved = raw === null ? null : Number(raw); } catch { /* не критично */ }
|
||||
pickServer(list.find(n => n.id === saved)?.id ?? list[0]?.id ?? null);
|
||||
}, [pickServer]);
|
||||
|
||||
// Проверяет токен и подтягивает список серверов. Данные конкретных вкладок
|
||||
// (юзеры, ключи) грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
await loadNodes(tok);
|
||||
}, [loadNodes]);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
|
||||
@@ -65,10 +102,31 @@ export default function App() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setStatusText('');
|
||||
setServerInfo(null);
|
||||
setNodes([]);
|
||||
setServerId(null);
|
||||
setServerIdState(null);
|
||||
setDrawerOpen(false);
|
||||
}, [token]);
|
||||
|
||||
const deleteNode = useCallback(async (n: NodeInfo) => {
|
||||
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
|
||||
try {
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
} catch (e) {
|
||||
// Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) —
|
||||
// панель объясняет, что останется, и удаляет только с согласия.
|
||||
const data = axios.isAxiosError(e) ? e.response?.data : undefined;
|
||||
if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e;
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token);
|
||||
}
|
||||
showMsg('Сервер удалён');
|
||||
await loadNodes(token);
|
||||
} catch (e) {
|
||||
showMsg(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось удалить' : 'Не удалось удалить');
|
||||
}
|
||||
}, [token, showMsg, loadNodes]);
|
||||
|
||||
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
|
||||
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
|
||||
const restartAwg = useCallback(async () => {
|
||||
@@ -96,6 +154,24 @@ export default function App() {
|
||||
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Токен протух (24 ч) или отозван, пока панель открыта: запросы вкладок падали с 401, а экран
|
||||
// оставался прежним. Выходим только по 401 самой панели («Unauthorized» — её JWT не принят):
|
||||
// 401 от awg-ctrl ноды (внутренняя авторизация) пересылается как есть и сессию не рвёт.
|
||||
useEffect(() => {
|
||||
const id = axios.interceptors.response.use(undefined, e => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401 && e.response.data?.error === 'Unauthorized') {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setNodes([]);
|
||||
setServerId(null);
|
||||
setServerIdState(null);
|
||||
showMsg('Сессия истекла — войдите снова');
|
||||
}
|
||||
return Promise.reject(e);
|
||||
});
|
||||
return () => axios.interceptors.response.eject(id);
|
||||
}, [showMsg]);
|
||||
|
||||
// Восстановить сессию из сохранённого токена
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY);
|
||||
@@ -107,11 +183,27 @@ export default function App() {
|
||||
}
|
||||
}, [startSession]);
|
||||
|
||||
// Применять и сохранять тему
|
||||
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
if (!token) return;
|
||||
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [token, loadNodes]);
|
||||
|
||||
// Имя продукта и версия — с сервера, только после логина: роут за авторизацией, чтобы
|
||||
// панель не опознавалась сканером. Если не ответил, остаётся фолбэк из констант.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
axios.get('/ui/brand', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(({ data }) => setBrand({
|
||||
brand: data.brand || BRAND_FALLBACK,
|
||||
channel: data.channel || '',
|
||||
version: data.version || '',
|
||||
}))
|
||||
.catch(() => { /* остаётся фолбэк */ });
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { document.title = token ? brand.brand : 'Вход'; }, [token, brand.brand]);
|
||||
|
||||
// Блокировать прокрутку фона когда drawer открыт
|
||||
useEffect(() => {
|
||||
@@ -119,6 +211,72 @@ export default function App() {
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [drawerOpen]);
|
||||
|
||||
// На широком окне (≥1200px) меню постоянное — модальный drawer там не нужен
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1200px)');
|
||||
const h = () => { if (mq.matches) setDrawerOpen(false); };
|
||||
mq.addEventListener('change', h);
|
||||
return () => mq.removeEventListener('change', h);
|
||||
}, []);
|
||||
|
||||
// Список серверов закрывается кликом мимо него и по Escape
|
||||
useEffect(() => {
|
||||
if (!serversOpen) return;
|
||||
const down = (e: MouseEvent) => {
|
||||
if (!serverSelectRef.current?.contains(e.target as Node)) setServersOpen(false);
|
||||
};
|
||||
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setServersOpen(false); };
|
||||
document.addEventListener('mousedown', down);
|
||||
document.addEventListener('keydown', key);
|
||||
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
|
||||
}, [serversOpen]);
|
||||
|
||||
// Есть ли встроенный Prometheus — от него зависит пункт «Монитор» (requires: 'prometheus').
|
||||
// Узнаём при входе, раз в минуту (могли поставить из CLI) и сразу от вкладок (announcePrometheus).
|
||||
// Анимации — только при переходах «не было → есть» и «было → нет», не при каждой загрузке страницы.
|
||||
const notePrometheus = useCallback((ready: boolean) => {
|
||||
setProm(p => (p?.ready === ready ? p : { ready, fresh: p !== null && ready, leaving: !!p?.ready && !ready }));
|
||||
}, []);
|
||||
// Уход длится --duration-medium2 (300 мс); потом пункт убираем из меню совсем
|
||||
useEffect(() => {
|
||||
if (!prom?.leaving) return;
|
||||
const t = setTimeout(() => setProm(p => (p?.leaving ? { ...p, leaving: false } : p)), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [prom?.leaving]);
|
||||
useEffect(() => {
|
||||
if (!token) { setProm(null); return; }
|
||||
const load = () => apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token)
|
||||
.then(r => notePrometheus(!!r.ready))
|
||||
.catch(() => { /* не знаем — меню не трогаем */ });
|
||||
void load();
|
||||
const t = setInterval(load, 60000);
|
||||
const h = (e: Event) => notePrometheus((e as CustomEvent<boolean>).detail);
|
||||
window.addEventListener('awg:prometheus', h);
|
||||
return () => { clearInterval(t); window.removeEventListener('awg:prometheus', h); };
|
||||
}, [token, notePrometheus]);
|
||||
// tabVisible — вкладка доступна; в меню она ещё и на время ухода
|
||||
const tabVisible = (t: { requires?: 'prometheus' }) => t.requires !== 'prometheus' || !!prom?.ready;
|
||||
const tabLeaving = (t: { requires?: 'prometheus' }) => t.requires === 'prometheus' && !!prom?.leaving;
|
||||
const navGroups = TABS_BY_GROUP
|
||||
.map(g => ({ ...g, tabs: g.tabs.filter(t => tabVisible(t) || tabLeaving(t)) }))
|
||||
.filter(g => g.tabs.length > 0);
|
||||
|
||||
// Активная вкладка пропала из меню (Prometheus удалили) — на первую
|
||||
useEffect(() => {
|
||||
const t = TABS.find(x => x.id === activeTab);
|
||||
if (t && !tabVisible(t)) setActiveTab(TABS[0].id);
|
||||
});
|
||||
|
||||
// Переход на другую вкладку из самой вкладки (openTab в lib/shared)
|
||||
useEffect(() => {
|
||||
const h = (e: Event) => {
|
||||
const id = (e as CustomEvent<string>).detail;
|
||||
if (TABS.some(t => t.id === id)) setActiveTab(id);
|
||||
};
|
||||
window.addEventListener('awg:open-tab', h);
|
||||
return () => window.removeEventListener('awg:open-tab', h);
|
||||
}, []);
|
||||
|
||||
// Закрывать drawer по Escape
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
@@ -127,40 +285,33 @@ export default function App() {
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [drawerOpen]);
|
||||
|
||||
const isOnline = statusText.startsWith('online:');
|
||||
const statusLabel = statusText.replace(/^(online|offline):/, '');
|
||||
const statusLabel = statusText.replace(/^(online|offline):/, '');
|
||||
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
|
||||
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
|
||||
const chrome = tab.chrome ?? {};
|
||||
const showServerBar = chrome.serverBar !== false;
|
||||
const showRestart = chrome.restart !== false;
|
||||
const current = nodes.find(n => n.id === serverId) ?? null;
|
||||
// Позиция вкладки в меню (по группам) — направление прокрутки заголовка-барабана
|
||||
const tabOrder = navGroups.flatMap(g => g.tabs).findIndex(t => t.id === tab.id);
|
||||
|
||||
const themeToggle = (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
|
||||
onClick={() => setThemeMode(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<button
|
||||
className="theme-toggle"
|
||||
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
|
||||
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
|
||||
>
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
|
||||
{token && showRestart && (
|
||||
<button
|
||||
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
||||
aria-label="Перезапустить AWG"
|
||||
title="Перезапустить AWG-интерфейс"
|
||||
onClick={restartAwg}
|
||||
disabled={restarting}
|
||||
>
|
||||
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!token ? (
|
||||
<div className="login-screen">
|
||||
<span className="theme-toggle--fixed">{themeToggle}</span>
|
||||
<div className="login-card">
|
||||
<span className="logo-name">Forgetting</span>
|
||||
<span className="logo-name">Вход</span>
|
||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||
<input
|
||||
className="field"
|
||||
@@ -196,7 +347,8 @@ export default function App() {
|
||||
>
|
||||
<IcoMenu />
|
||||
</button>
|
||||
<span className="top-bar-title">{tab.label}</span>
|
||||
<span className="top-bar-title"><DrumTitle text={tab.label} order={tabOrder} /></span>
|
||||
<span className="top-bar-end">{themeToggle}</span>
|
||||
</header>
|
||||
|
||||
{/* Затемнение под drawer */}
|
||||
@@ -212,47 +364,144 @@ export default function App() {
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Кнопка меню rail (600–1199px): раскрывает его в модальный drawer */}
|
||||
<button
|
||||
className="rail-menu"
|
||||
aria-label={drawerOpen ? 'Свернуть меню' : 'Развернуть меню'}
|
||||
aria-expanded={drawerOpen}
|
||||
onClick={() => setDrawerOpen(o => !o)}
|
||||
>
|
||||
<IcoMenu />
|
||||
</button>
|
||||
<div className="sidebar-logo">
|
||||
<span className="logo-name">Forgetting</span>
|
||||
<span className="sidebar-version">Alpha 0.1.4.1</span>
|
||||
<span className="logo-name">{brand.brand}</span>
|
||||
<span className="sidebar-version">
|
||||
{[brand.channel, brand.version].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{TABS.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
{navGroups.map(g => (
|
||||
<nav key={g.id} className="nav-group" aria-label={g.label}>
|
||||
<span className="nav-group-title">{g.label}</span>
|
||||
{g.tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
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); }}
|
||||
>
|
||||
<span className="nav-icon"><t.Icon /></span>
|
||||
<span className="nav-label">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
))}
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<button className="btn btn--danger" onClick={logout}>
|
||||
<IcoLogout /> Выйти
|
||||
<button className="btn btn--danger sidebar-logout" aria-label="Выйти" onClick={logout}>
|
||||
<IcoLogout /> <span className="nav-label">Выйти</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<h2 className="page-title">{tab.label}</h2>
|
||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
||||
{showServerBar && (
|
||||
<ServerBar
|
||||
serverInfo={serverInfo}
|
||||
serverOnline={isOnline}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showAddServer={chrome.addServer !== false}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
{/* Шапка страницы: заголовок слева; справа — выбор сервера, перезапуск AWG и тема.
|
||||
На телефоне заголовок и тема — в верхнем баре, выбор сервера — во всю ширину. */}
|
||||
<div className="page-head">
|
||||
<h2 className="page-title"><DrumTitle text={tab.label} order={tabOrder} /></h2>
|
||||
<div className="page-actions">
|
||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают.
|
||||
Текущий сервер — кнопкой, остальные — выпадающим списком под ней. */}
|
||||
{showServerBar && (
|
||||
<div className="server-select" ref={serverSelectRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`server-current${serversOpen ? ' server-current--open' : ''}`}
|
||||
aria-expanded={serversOpen}
|
||||
aria-controls="server-menu"
|
||||
onClick={() => setServersOpen(o => !o)}
|
||||
>
|
||||
<span
|
||||
className={`status-dot${current?.online ? ' status-dot--online' : ''}`}
|
||||
role="img"
|
||||
aria-label={current?.online ? 'на связи' : 'нет связи'}
|
||||
/>
|
||||
<span className="server-current-info">
|
||||
<span className="server-card-name">
|
||||
{current ? (current.health?.server || current.name) : 'Сервер не выбран'}
|
||||
</span>
|
||||
<span className="server-card-ip">
|
||||
{!current ? 'добавьте ноду'
|
||||
: current.pending ? 'ждёт подключения'
|
||||
: current.outdated ? `нужно обновить до ${MIN_NODE_VERSION}`
|
||||
: !current.online ? 'нет связи'
|
||||
: current.health?.ip}
|
||||
</span>
|
||||
</span>
|
||||
<span className="server-current-count">{serversLabel(nodes.length)}</span>
|
||||
<span className="server-current-caret"><IcoChevronDown /></span>
|
||||
</button>
|
||||
{serversOpen && (
|
||||
<div className="server-menu" id="server-menu" aria-label="Серверы">
|
||||
<ServerBar
|
||||
nodes={nodes}
|
||||
serverId={serverId}
|
||||
hubEnabled={hubEnabled}
|
||||
panelVersion={panelVersion}
|
||||
onSelect={id => { pickServer(id); setServersOpen(false); }}
|
||||
onAdd={() => { setServersOpen(false); setModal({}); }}
|
||||
onJoin={n => { setServersOpen(false); setModal({ node: n }); }}
|
||||
onDelete={deleteNode}
|
||||
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
|
||||
onSettings={n => { setServersOpen(false); pickServer(n.id); openTab('settings', 'server'); }}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showRestart && serverId !== null && (
|
||||
<button
|
||||
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
||||
aria-label="Перезапустить AWG"
|
||||
title="Перезапустить AWG-интерфейс"
|
||||
onClick={restartAwg}
|
||||
disabled={restarting}
|
||||
>
|
||||
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
|
||||
</button>
|
||||
)}
|
||||
<span className="page-theme">{themeToggle}</span>
|
||||
</div>
|
||||
</div>
|
||||
{serverId === null && showServerBar ? (
|
||||
<p className="login-sub">
|
||||
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
|
||||
</p>
|
||||
) : (
|
||||
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
|
||||
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
|
||||
)}
|
||||
<tab.Page token={token} showMsg={showMsg} />
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<AddServerModal
|
||||
token={token}
|
||||
node={modal.node}
|
||||
onClose={() => setModal(null)}
|
||||
onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }}
|
||||
showMsg={showMsg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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 { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { apiFetch, copyText, type NodeInfo } from '../lib/shared';
|
||||
import { IcoCopy } from './icons';
|
||||
|
||||
interface Joined { id: number; name: string; join: string; expires_in: number }
|
||||
|
||||
// Добавление ноды: имя → одноразовая join-строка, которую выполняют на самой ноде
|
||||
// (`awg-ctrl join …`). С `node` открывается сразу за новой строкой для уже
|
||||
// созданной, но ещё не подключавшейся ноды.
|
||||
export default function AddServerModal({ token, node, onClose, onChanged, showMsg }: {
|
||||
token: string;
|
||||
node?: NodeInfo;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
showMsg: (text: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<Joined | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const call = async (fn: () => Promise<Joined>) => {
|
||||
setBusy(true); setError('');
|
||||
try { setResult(await fn()); onChanged(); }
|
||||
catch (e) { setError(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось' : 'Не удалось'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const create = () => call(() =>
|
||||
apiFetch('POST', '/ui/nodes', token, { name: name.trim(), host: window.location.hostname }));
|
||||
|
||||
useEffect(() => {
|
||||
if (node) void call(() => apiFetch('POST', `/ui/nodes/${node.id}/join`, token, { host: window.location.hostname }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const command = result ? `awg-ctrl join '${result.join}'` : '';
|
||||
|
||||
return (
|
||||
<div className="node-scrim" onClick={onClose}>
|
||||
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Добавить сервер" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="node-modal-title">{node ? `Подключение: ${node.name}` : 'Добавить сервер'}</h3>
|
||||
|
||||
{!node && !result && (
|
||||
<>
|
||||
<p className="node-modal-hint">
|
||||
Нода — обычный сервер с установленным Forgetting. Она сама подключится к этой панели,
|
||||
открывать на ней порты не нужно.
|
||||
</p>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Название сервера"
|
||||
value={name}
|
||||
maxLength={40}
|
||||
autoFocus
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && !busy && create()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<p className="node-modal-hint">
|
||||
Выполните на сервере «{result.name}» от root. Ссылка одноразовая и действует
|
||||
{' '}{Math.round(result.expires_in / 60)} мин.
|
||||
</p>
|
||||
<code className="node-modal-cmd">{command}</code>
|
||||
<p className="node-modal-hint">
|
||||
Канал шифруется TLS с проверкой отпечатка сертификата этой панели: подменить ноду
|
||||
или панель по дороге не выйдет.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
<div className="node-modal-actions">
|
||||
<button className="btn btn--text" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
|
||||
{result ? (
|
||||
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
) : !node && (
|
||||
<button className="btn btn--primary" onClick={create} disabled={busy || !name.trim()}>
|
||||
Создать
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности,
|
||||
// перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками.
|
||||
// Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста.
|
||||
export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] }
|
||||
|
||||
const M = { left: 46, right: 14, top: 10, bottom: 24 };
|
||||
|
||||
// Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125.
|
||||
function niceScale(max: number): { top: number; ticks: number[] } {
|
||||
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
|
||||
const raw = max / 4;
|
||||
const p = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||
const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p;
|
||||
const n = Math.max(1, Math.ceil(max / step - 1e-9));
|
||||
return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) };
|
||||
}
|
||||
|
||||
const timeLabel = (t: number, span: number) => {
|
||||
const d = new Date(t * 1000);
|
||||
const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm;
|
||||
};
|
||||
|
||||
export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: {
|
||||
title: string;
|
||||
series: ChartSeries[];
|
||||
from: number;
|
||||
to: number;
|
||||
step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост
|
||||
format: (v: number) => string; // значение в подсказке и таблице
|
||||
formatAxis?: (v: number) => string; // компактная подпись оси
|
||||
max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным
|
||||
dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее
|
||||
}) {
|
||||
const wrap = useRef<HTMLDivElement>(null); // содержимое карточки без её padding: от него зависит ширина svg
|
||||
const [w, setW] = useState(640);
|
||||
const [hover, setHover] = useState<number | null>(null); // индекс по оси времени
|
||||
const [table, setTable] = useState(false);
|
||||
const H = 200;
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrap.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth)));
|
||||
ro.observe(el); setW(Math.max(260, el.clientWidth));
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу).
|
||||
const times = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
for (const s of series) for (const p of s.points) set.add(p[0]);
|
||||
return [...set].sort((a, b) => a - b);
|
||||
}, [series]);
|
||||
const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]);
|
||||
|
||||
const empty = times.length === 0;
|
||||
const { top: yMax, ticks: yTicks } = max !== undefined
|
||||
? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) }
|
||||
: niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04);
|
||||
const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right);
|
||||
const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom);
|
||||
|
||||
const path = (pts: [number, number][]) => {
|
||||
let d = '', prev: number | null = null;
|
||||
for (const [t, v] of pts) {
|
||||
d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`;
|
||||
prev = t;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const span = to - from;
|
||||
const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i);
|
||||
const axis = formatAxis ?? format;
|
||||
|
||||
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
if (!times.length) return;
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span;
|
||||
let best = 0;
|
||||
for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
|
||||
setHover(best);
|
||||
};
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (!times.length) return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); }
|
||||
if (e.key === 'Escape') setHover(null);
|
||||
};
|
||||
|
||||
const ht = hover !== null ? times[hover] : null;
|
||||
// Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия
|
||||
const flip = ht !== null && x(ht) > w * 0.55;
|
||||
|
||||
// Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая
|
||||
const rows = useMemo(() => {
|
||||
if (!times.length) return [] as number[];
|
||||
const n = Math.min(12, times.length);
|
||||
return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse();
|
||||
}, [times]);
|
||||
|
||||
return (
|
||||
<figure className={`mon-chart${dim ? ' mon-chart--dim' : ''}`}>
|
||||
<figcaption className="mon-chart-head">
|
||||
<span className="mon-chart-title">{title}</span>
|
||||
{series.length >= 2 && (
|
||||
<span className="mon-legend">
|
||||
{series.map(s => (
|
||||
<span className="mon-legend-item" key={s.id}>
|
||||
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />{s.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="mon-chart-toggle" aria-pressed={table} onClick={() => setTable(t => !t)}>
|
||||
{table ? 'График' : 'Таблица'}
|
||||
</button>
|
||||
</figcaption>
|
||||
|
||||
<div ref={wrap}>
|
||||
{table ? (
|
||||
<div className="mon-table-wrap">
|
||||
<table className="mon-table">
|
||||
<thead><tr><th>Время</th>{series.map(s => <th key={s.id}>{s.name}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={series.length + 1}>Нет данных</td></tr> : rows.map(t => (
|
||||
<tr key={t}>
|
||||
<td>{timeLabel(t, span)}</td>
|
||||
{series.map((s, i) => <td key={s.id}>{byTime[i].has(t) ? format(byTime[i].get(t)!) : '—'}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mon-plot">
|
||||
<svg width={w} height={H} role="img" tabIndex={0} aria-label={`${title}. Стрелки влево и вправо — значения по времени.`}
|
||||
onKeyDown={onKey} onBlur={() => setHover(null)}>
|
||||
{yTicks.map((v, i) => (
|
||||
<g key={i}>
|
||||
<line className="mon-grid" x1={M.left} x2={w - M.right} y1={y(v)} y2={y(v)} />
|
||||
<text className="mon-tick" x={M.left - 8} y={y(v) + 4} textAnchor="end">{axis(v)}</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((t, i) => (
|
||||
<text key={i} className="mon-tick" x={x(t)} y={H - 6} textAnchor={i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}>
|
||||
{timeLabel(t, span)}
|
||||
</text>
|
||||
))}
|
||||
{!empty && series.map(s => (
|
||||
<path key={s.id} d={path(s.points)} fill="none" style={{ stroke: s.color }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
))}
|
||||
{/* конечная точка: r 4 + кольцо цвета поверхности, чтобы читалась поверх линий */}
|
||||
{!empty && series.map(s => s.points.length > 0 && (
|
||||
<circle key={s.id} cx={x(s.points[s.points.length - 1][0])} cy={y(s.points[s.points.length - 1][1])}
|
||||
r={4} style={{ fill: s.color }} className="mon-end" />
|
||||
))}
|
||||
{ht !== null && (
|
||||
<g>
|
||||
<line className="mon-cross" x1={x(ht)} x2={x(ht)} y1={M.top} y2={H - M.bottom} />
|
||||
{series.map((s, i) => byTime[i].has(ht) && (
|
||||
<circle key={s.id} cx={x(ht)} cy={y(byTime[i].get(ht)!)} r={4} style={{ fill: s.color }} className="mon-end" />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
{/* зона наведения шире линии: указатель ищет момент времени, а не 2-пиксельную линию */}
|
||||
<rect x={M.left} y={M.top} width={Math.max(0, w - M.left - M.right)} height={H - M.top - M.bottom}
|
||||
fill="transparent" onPointerMove={onMove} onPointerLeave={() => setHover(null)} />
|
||||
</svg>
|
||||
{empty && <p className="mon-empty">Данные появятся через минуту-две после запуска Prometheus</p>}
|
||||
{ht !== null && (
|
||||
<div className="mon-tip" style={{ left: flip ? undefined : x(ht) + 12, right: flip ? w - x(ht) + 12 : undefined, top: M.top }} role="status">
|
||||
<span className="mon-tip-time">{timeLabel(ht, span)}</span>
|
||||
{series.map((s, i) => (
|
||||
<span className="mon-tip-row" key={s.id}>
|
||||
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />
|
||||
<b>{byTime[i].has(ht) ? format(byTime[i].get(ht)!) : '—'}</b>
|
||||
<span>{s.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import type { Priority } from '../lib/shared';
|
||||
|
||||
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
|
||||
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
|
||||
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
|
||||
// деления канала) чип не показываем: «обычный» там было бы выдумкой. С меткой «торренты» устройство
|
||||
// фоновое независимо от загрузки канала — подсказка говорит об этом.
|
||||
export default function PriorityChip({ priority, torrent }: { priority?: Priority; torrent?: boolean }) {
|
||||
if (!priority) return null;
|
||||
const bg = priority === 'background';
|
||||
return (
|
||||
<span
|
||||
className="tip-wrap"
|
||||
data-tip={!bg
|
||||
? 'Обычный приоритет: канал делится поровну'
|
||||
: torrent
|
||||
? 'Пытается качать торренты — пока висит метка, устройство уступает остальным'
|
||||
: 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'}
|
||||
>
|
||||
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
|
||||
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,144 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { type ServerInfo } from '../lib/shared';
|
||||
import { IcoPlus, IcoRefresh } from './icons';
|
||||
import { type NodeInfo, genLabel, cmpVersion, MIN_NODE_VERSION } from '../lib/shared';
|
||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
|
||||
|
||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
||||
// Версия Forgetting на сервере, три состояния: совпадает с панелью — нейтральный чип; нода отстаёт
|
||||
// от панели (но не старше MIN_NODE_VERSION) или новее неё — жёлтый, стоит обновить; старше
|
||||
// MIN_NODE_VERSION или не сообщает версию (агент старше 0.3.4) — красный: хаб такую ноду не подключает.
|
||||
function versionChip(n: NodeInfo, panel: string | null) {
|
||||
if (n.pending) return null;
|
||||
let tip = '', level: '' | 'warn' | 'bad' = '';
|
||||
if (!n.version) {
|
||||
level = 'bad';
|
||||
tip = `Версия неизвестна: нода старше 0.3.4, панель с ней не работает — обнови её до ${MIN_NODE_VERSION} (install.sh → «Обновить»)`;
|
||||
} else if (!n.local && cmpVersion(n.version, MIN_NODE_VERSION) < 0) {
|
||||
level = 'bad';
|
||||
tip = `Слишком старая версия: панель с ней не работает, нужна не ниже ${MIN_NODE_VERSION} — обнови ноду (install.sh → «Обновить»)`;
|
||||
} else if (panel && !n.local) {
|
||||
const d = cmpVersion(n.version, panel);
|
||||
if (d < 0) { level = 'warn'; tip = `Устарела: панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
|
||||
else if (d > 0) { level = 'warn'; tip = `Нода новее панели (${panel}) — обнови панель`; }
|
||||
}
|
||||
const chip = (
|
||||
<span className={`chip server-ver-chip${level ? ` server-ver-chip--${level}` : ''}`}>
|
||||
{n.version ? `v${n.version}` : 'v?.?.?'}
|
||||
</span>
|
||||
);
|
||||
return tip ? <span className="tip-wrap server-gen-wrap" data-tip={tip}>{chip}</span> : chip;
|
||||
}
|
||||
|
||||
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
|
||||
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает)
|
||||
// сервер, к которому относится содержимое вкладки (пиры / API-ключи).
|
||||
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
|
||||
// станет несколько — карточки станут кликабельными, активная = выбранная.
|
||||
export default function ServerBar({
|
||||
serverInfo, serverOnline, onRestartAwg, restarting,
|
||||
showAddServer = true, showRestart = true,
|
||||
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onSettings,
|
||||
onRestartAwg, restarting, showRestart = true,
|
||||
}: {
|
||||
serverInfo: ServerInfo | null;
|
||||
serverOnline: boolean;
|
||||
nodes: NodeInfo[];
|
||||
panelVersion: string | null;
|
||||
serverId: number | null;
|
||||
hubEnabled: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
onAdd: () => void;
|
||||
onJoin: (n: NodeInfo) => void;
|
||||
onDelete: (n: NodeInfo) => void;
|
||||
onSettings: (n: NodeInfo) => void;
|
||||
onRestartAwg: () => void;
|
||||
restarting: boolean;
|
||||
showAddServer?: boolean;
|
||||
showRestart?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="server-bar">
|
||||
{serverInfo && (
|
||||
<div className="server-card server-card--active">
|
||||
{/* toggle — виден на мобильном вместо чипа */}
|
||||
{nodes.map(n => {
|
||||
const h = n.health;
|
||||
const active = n.id === serverId;
|
||||
return (
|
||||
<div
|
||||
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
|
||||
aria-hidden="true"
|
||||
key={n.id}
|
||||
className={`server-card server-card--${active ? 'active' : 'inactive'}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelect(n.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
|
||||
>
|
||||
<div className="server-toggle-thumb" />
|
||||
<span
|
||||
className={`status-dot${n.online ? ' status-dot--online' : ''}`}
|
||||
role="img"
|
||||
aria-label={n.online ? 'на связи' : 'нет связи'}
|
||||
/>
|
||||
<div className="server-card-info">
|
||||
<span className="server-card-name">{h?.server || n.name}</span>
|
||||
{n.pending ? (
|
||||
<span className="server-card-ip">ждёт подключения</span>
|
||||
) : n.outdated ? (
|
||||
<span className="server-card-ip server-card-ip--bad">нужно обновить до {MIN_NODE_VERSION}</span>
|
||||
) : !n.online ? (
|
||||
<span className="server-card-ip">нет связи</span>
|
||||
) : h && (
|
||||
<span className="server-card-ip">{h.ip}</span>
|
||||
)}
|
||||
{/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */}
|
||||
{!n.pending && (
|
||||
<span className="server-chips">
|
||||
{h && n.online && ((h.module || h.tools) ? (
|
||||
<span
|
||||
className="tip-wrap server-gen-wrap"
|
||||
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
|
||||
>
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
||||
))}
|
||||
{versionChip(n, panelVersion)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
|
||||
{n.pending && (
|
||||
<button
|
||||
className="btn-icon btn-icon--neutral"
|
||||
aria-label="Новая ссылка подключения"
|
||||
onClick={e => { e.stopPropagation(); onJoin(n); }}
|
||||
>
|
||||
<IcoCopy />
|
||||
</button>
|
||||
)}
|
||||
{/* Настройки сервера: у ноды, которая ещё не подключалась, настраивать нечего */}
|
||||
{!n.pending && n.online && (
|
||||
<button
|
||||
className="btn-icon btn-icon--neutral"
|
||||
aria-label={`Настройки сервера ${n.name}`}
|
||||
onClick={e => { e.stopPropagation(); onSettings(n); }}
|
||||
>
|
||||
<IcoSettings />
|
||||
</button>
|
||||
)}
|
||||
{!n.local && (
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
aria-label={`Удалить сервер ${n.name}`}
|
||||
onClick={e => { e.stopPropagation(); onDelete(n); }}
|
||||
>
|
||||
<IcoTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
||||
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<div className="server-card-info">
|
||||
<span className="server-card-name">{serverInfo.name}</span>
|
||||
<span className="server-card-ip">{serverInfo.ip}</span>
|
||||
</div>
|
||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||
</div>
|
||||
)}
|
||||
{showAddServer && (
|
||||
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
|
||||
<button className="server-add" disabled>
|
||||
<IcoPlus /> Добавить сервер
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
<span
|
||||
className="tip-wrap"
|
||||
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
|
||||
>
|
||||
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
|
||||
<IcoPlus /> Добавить сервер
|
||||
</button>
|
||||
</span>
|
||||
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
||||
{showRestart && (
|
||||
{showRestart && serverId !== null && (
|
||||
<button
|
||||
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
|
||||
onClick={onRestartAwg}
|
||||
@@ -61,4 +149,4 @@ export default function ServerBar({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { type NodeInfo, genLabel } from '../lib/shared';
|
||||
|
||||
// Выбор набора серверов галочками: серверы мастер-ключа, видимость API-ключа. Набор не
|
||||
// бывает пустым — последний отмеченный сервер снять нельзя. onChange получает новый набор
|
||||
// и сервер, который переключили.
|
||||
export default function ServerPicker({ nodes, selected, onChange }: {
|
||||
nodes: NodeInfo[];
|
||||
selected: number[];
|
||||
onChange: (next: number[], node: NodeInfo, added: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(n => {
|
||||
const on = selected.includes(n.id);
|
||||
const last = on && selected.length === 1;
|
||||
const h = n.health;
|
||||
return (
|
||||
<label className={`server-pick${last ? ' server-pick--locked' : ''}`} key={n.id}>
|
||||
<input type="checkbox" checked={on} disabled={last}
|
||||
onChange={() => onChange(on ? selected.filter(id => id !== n.id) : [...selected, n.id], n, !on)} />
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="server-pick-name">{h?.server || n.name}</span>
|
||||
{h?.ip && <code className="server-pick-ip">{h.ip}</code>}
|
||||
{h?.gen && <span className="server-pick-gen">{genLabel(h.gen)}</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Имя сервера для подписей: как в сервер-баре, иначе «#id».
|
||||
export const serverName = (nodes: NodeInfo[], id: number): string => {
|
||||
const n = nodes.find(x => x.id === id);
|
||||
return n ? n.health?.server || n.name : `#${id}`;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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.
|
||||
|
||||
// Пометка «торренты»: устройство за последние 10 минут пыталось открыть торрент-соединение, и оно
|
||||
// было заблокировано. Без флага (блокировка выключена или awg-ctrl старше) чип не показываем.
|
||||
export default function TorrentChip({ torrent }: { torrent?: boolean }) {
|
||||
if (!torrent) return null;
|
||||
return (
|
||||
<span className="tip-wrap" data-tip="За последние 10 минут пыталось качать торренты: соединения заблокированы, а при делении канала устройство в фоновом приоритете">
|
||||
<span className="chip chip--error"><span className="chip-dot" />торренты</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const IcoMenu = () => (
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoChevronDown = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoUsers = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
@@ -35,6 +40,13 @@ export const IcoRefresh = () => (
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoSettings = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>
|
||||
<circle cx="9" cy="7" r="2.5" fill="var(--surface-bright, transparent)"/>
|
||||
<circle cx="15" cy="17" r="2.5" fill="var(--surface-bright, transparent)"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoQR = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
@@ -83,7 +95,55 @@ export const IcoMoon = () => (
|
||||
|
||||
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
|
||||
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
|
||||
export const IcoShield = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoPulse = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>
|
||||
</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> = {
|
||||
settings: IcoGear,
|
||||
users: IcoUsers,
|
||||
key: IcoKey,
|
||||
shield: IcoShield,
|
||||
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);
|
||||
}
|
||||
+103
-4
@@ -5,6 +5,9 @@
|
||||
import axios from 'axios';
|
||||
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
||||
export type AwgGen = '2' | '3.1';
|
||||
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
|
||||
export type Priority = 'normal' | 'background';
|
||||
|
||||
export interface User {
|
||||
name: string;
|
||||
ip: string;
|
||||
@@ -15,29 +18,125 @@ export interface User {
|
||||
lastHandshake: number;
|
||||
rx?: number;
|
||||
tx?: number;
|
||||
priority?: Priority; // нет у awg-ctrl старше деления канала
|
||||
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
|
||||
}
|
||||
export interface ServerInfo {
|
||||
name: string;
|
||||
ip: string;
|
||||
peers: number;
|
||||
gen: AwgGen;
|
||||
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
|
||||
tools: string; // версия awg-tools, из /health awg.tools
|
||||
}
|
||||
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
|
||||
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
|
||||
// Ключ внешнего API в списке вкладки: открытого значения тут нет — только
|
||||
// префикс, сервер хранит лишь хэш.
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
label: string;
|
||||
prefix: string;
|
||||
server_id: number;
|
||||
server_id: number; // сервер по умолчанию для /api/v1/users
|
||||
servers: number[]; // какие серверы видит ключ
|
||||
created_at: number;
|
||||
last_used: number | null;
|
||||
}
|
||||
|
||||
export interface PageProps {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
}
|
||||
export const TOKEN_KEY = 'awg_token';
|
||||
export const THEME_KEY = 'awg_theme';
|
||||
// Имя панели, канал и версия приходят с сервера (GET /ui/brand; значения — из
|
||||
// .env в корне проекта) — переименование и бамп версии живут в одном файле.
|
||||
// Эта константа — фолбэк на время загрузки и при недоступности роута.
|
||||
export const BRAND_FALLBACK = 'Forgetting';
|
||||
export interface Brand { brand: string; channel: string; version: string }
|
||||
|
||||
export const TOKEN_KEY = 'awg_token';
|
||||
export const THEME_KEY = 'awg_theme';
|
||||
export const SERVER_KEY = 'awg_server';
|
||||
export const SETTINGS_SECTION_KEY = 'awg_settings_section'; // открытый раздел «Настроек»
|
||||
|
||||
// Встроенный Prometheus установлен или удалён: вкладка «Монитор» в меню есть только при нём.
|
||||
// Кто узнал состояние (раздел установки, сам «Монитор») — сообщает App, тот прячет или показывает пункт.
|
||||
export const announcePrometheus = (ready: boolean) =>
|
||||
window.dispatchEvent(new CustomEvent('awg:prometheus', { detail: ready }));
|
||||
|
||||
// Открыть другую вкладку панели (App слушает событие). section — раздел «Настроек»: его вкладка
|
||||
// читает из SETTINGS_SECTION_KEY при монтировании, поэтому пишем до перехода.
|
||||
export function openTab(id: string, section?: string) {
|
||||
if (section) try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
|
||||
window.dispatchEvent(new CustomEvent('awg:open-tab', { detail: id }));
|
||||
}
|
||||
|
||||
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
|
||||
export interface NodeInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
local: boolean; // id 0 — локальный awg-ctrl этой же машины
|
||||
online: boolean;
|
||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
||||
last_seen: number | null;
|
||||
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
|
||||
outdated?: boolean; // старше MIN_NODE_VERSION: панель её не подключает, пока не обновят
|
||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||
}
|
||||
|
||||
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
|
||||
export interface ShapingStatus {
|
||||
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
|
||||
active: boolean; // CAKE реально стоит на интерфейсе
|
||||
congested: boolean;
|
||||
heavy: string[]; // IP устройств, переведённых в фоновый класс
|
||||
devices: number; // активных устройств в окне
|
||||
rateDown: number; // Мбит/с за последний тик
|
||||
rateUp: number;
|
||||
}
|
||||
|
||||
// Блокировка BitTorrent (GET/PUT /api/torrent на awg-ctrl сервера).
|
||||
export interface TorrentStatus {
|
||||
settings: { enabled: boolean };
|
||||
active: boolean; // nft-таблица реально стоит
|
||||
tcp: boolean; // проверяется и TCP-рукопожатие (нужен свежий nftables)
|
||||
hits: number; // заблокированных попыток с последнего применения
|
||||
seen: { ip: string; name: string }[]; // кто пытался за последние 10 минут
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// «1 сервер», «3 сервера», «5 серверов»
|
||||
export function serversLabel(n: number): string {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
const word = m10 === 1 && m100 !== 11 ? 'сервер'
|
||||
: m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'сервера' : 'серверов';
|
||||
return `${n} ${word}`;
|
||||
}
|
||||
|
||||
// Минимальная версия Forgetting на ноде (в хабе — MinNodeVersion, internal/ui/nodes.go): старше — хаб
|
||||
// ноду не подключает, чип версии красный; между ней и версией панели — жёлтый (просто стоит обновить).
|
||||
export const MIN_NODE_VERSION = '0.5.0';
|
||||
|
||||
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||
export function cmpVersion(a: string, b: string): number {
|
||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||
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;
|
||||
}
|
||||
|
||||
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
||||
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
||||
// перечитывает данные уже с новым сервером.
|
||||
let currentServerId: number | null = null;
|
||||
export const setServerId = (id: number | null) => { currentServerId = id; };
|
||||
export const getServerId = () => currentServerId;
|
||||
|
||||
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
||||
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
||||
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
|
||||
const r = await axios({ method, url: path, headers, data: body });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import axios from 'axios';
|
||||
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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
Copyright (c) 2026 Ivan Vasilev
|
||||
This source code is licensed under the MIT license found in the
|
||||
LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
|
||||
|
||||
/* Открытый API-ключ (показывается один раз при создании) */
|
||||
.apikey-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.apikey-count { font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-empty {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.apikey-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.apikey-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.apikey-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.apikey-card-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.apikey-card-prefix {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--neutral-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.apikey-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.apikey-cards-list { display: flex; }
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||
import './apikeys.css';
|
||||
|
||||
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
||||
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
|
||||
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
|
||||
// серверов — ключ будет привязываться к выбранному в сервер-баре.
|
||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/ui/apikeys', token);
|
||||
setKeys(data.keys ?? []);
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки ключей');
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
await load();
|
||||
}, [label, token, load, showMsg]);
|
||||
|
||||
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
||||
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
||||
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
|
||||
showMsg('Ключ удалён: ' + lbl);
|
||||
await load();
|
||||
}, [token, load, showMsg]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
|
||||
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Метка ключа (напр. ci-bot)"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createKey()}
|
||||
/>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={createKey}>
|
||||
<IcoPlus /> Создать ключ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
|
||||
<div className="apikey-cards-list">
|
||||
<p className="apikey-count">{keys.length} ключей</p>
|
||||
{keys.length === 0 ? (
|
||||
<p className="apikey-empty">Нет ключей</p>
|
||||
) : keys.map((k, i) => (
|
||||
<div className="apikey-card" key={k.id}>
|
||||
<div className="apikey-card-header">
|
||||
<div className="apikey-card-title">
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{created && (
|
||||
<div className="qr-backdrop" onClick={() => setCreated(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">Ключ «{created.label}» создан</p>
|
||||
<p className="qr-hint">
|
||||
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
|
||||
</p>
|
||||
<code className="apikey-value">{created.key}</code>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
await copyText(created.key);
|
||||
showMsg('Ключ скопирован');
|
||||
}}><IcoCopy /> Скопировать</button>
|
||||
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "apikeys",
|
||||
"label": "API-ключи",
|
||||
"icon": "key",
|
||||
"order": 2
|
||||
}
|
||||
@@ -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>/:
|
||||
// index.tsx — компонент + логика
|
||||
// <name>.css — стили вкладки (импортит сам компонент)
|
||||
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок)
|
||||
// metadata.json — { id, label, icon, group?, order?, requires?, chrome? } (манифест: описание, группа меню, порядок)
|
||||
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
||||
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
||||
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
||||
@@ -18,16 +18,24 @@ import { ICONS } from '../components/icons';
|
||||
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
||||
export interface TabChrome {
|
||||
serverBar?: boolean;
|
||||
addServer?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
// Группы меню: порядок здесь = порядок заголовков в сайдбаре. Новая группа — строка сюда.
|
||||
export const TAB_GROUPS = [
|
||||
{ id: 'main', label: 'Основное' },
|
||||
{ id: 'server', label: 'О сервере' },
|
||||
] as const;
|
||||
export type TabGroupId = (typeof TAB_GROUPS)[number]['id'];
|
||||
|
||||
// Сериализуемая часть из metadata.json.
|
||||
export interface TabMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // имя иконки → ICONS
|
||||
order?: number; // порядок в меню (по возрастанию; без него — 0)
|
||||
group: TabGroupId; // группа меню; без поля — 'main'
|
||||
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
|
||||
requires?: 'prometheus'; // вкладка в меню только при условии: 'prometheus' — встроенный Prometheus установлен
|
||||
chrome?: TabChrome;
|
||||
}
|
||||
|
||||
@@ -59,11 +67,20 @@ function toMeta(name: string, raw: unknown): TabMeta {
|
||||
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
||||
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
|
||||
}
|
||||
if (m.requires !== undefined && m.requires !== 'prometheus') {
|
||||
throw new Error(`tabs/${name}/metadata.json: "requires" может быть только "prometheus"`);
|
||||
}
|
||||
const group = (m.group ?? 'main') as string;
|
||||
if (!TAB_GROUPS.some(g => g.id === group)) {
|
||||
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
|
||||
}
|
||||
return {
|
||||
id: str('id'),
|
||||
label: str('label'),
|
||||
icon: str('icon'),
|
||||
group: group as TabGroupId,
|
||||
order: m.order as number | undefined,
|
||||
requires: m.requires as 'prometheus' | undefined,
|
||||
chrome: m.chrome as TabChrome | undefined,
|
||||
};
|
||||
}
|
||||
@@ -81,7 +98,15 @@ export const TABS: TabDef[] = Object.entries(metas)
|
||||
}
|
||||
return { ...meta, Icon: ICONS[meta.icon], Page: page };
|
||||
})
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
// сначала по группе (как в TAB_GROUPS), внутри группы — по order
|
||||
.sort((a, b) =>
|
||||
TAB_GROUPS.findIndex(g => g.id === a.group) - TAB_GROUPS.findIndex(g => g.id === b.group)
|
||||
|| (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
// Вкладки по группам для меню; пустые группы не показываем.
|
||||
export const TABS_BY_GROUP = TAB_GROUPS
|
||||
.map(g => ({ ...g, tabs: TABS.filter(t => t.group === g.id) }))
|
||||
.filter(g => g.tabs.length > 0);
|
||||
|
||||
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
||||
const ids = TABS.map(t => t.id);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// 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 { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { apiFetch, copyText, bytes, timeAgo, getServerId, cmpVersion, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
||||
import { qrDataUrl } from '../../lib/qr';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoMore } from '../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||
import PriorityChip from '../../components/PriorityChip';
|
||||
import TorrentChip from '../../components/TorrentChip';
|
||||
import BulkServersModal from './BulkServersModal';
|
||||
import './masterkeys.css';
|
||||
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface Device {
|
||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean;
|
||||
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
|
||||
}
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
|
||||
// Приоритет показываем, только когда он не обычный: «обычный» в каждой строке — шум
|
||||
const bgOnly = (p?: Priority) => p === 'background' ? p : undefined;
|
||||
|
||||
const statusChip = (d: Device) => (
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'}
|
||||
</span>
|
||||
);
|
||||
|
||||
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
|
||||
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
|
||||
const ipPick = (d: Device): { ip: string; serverId?: number; other: boolean } => {
|
||||
const own = d.ips?.find(x => x.server_id === getServerId());
|
||||
const pick = own ?? d.ips?.[0];
|
||||
return { ip: pick?.ip ?? '—', serverId: pick?.server_id, other: !own && !!pick };
|
||||
};
|
||||
|
||||
export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<MasterKey[]>([]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [tls, setTls] = useState(false);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
|
||||
// Маскировка первого пакета (I1) сервера из шапки: false — сервер её не умеет (старый awg-ctrl) или недоступен
|
||||
const [masking, setMasking] = useState<'ready' | 'busy' | false>(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const [limit, setLimit] = useState('3');
|
||||
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
||||
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
|
||||
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
|
||||
// Меню «⋮» ключа или устройства. position: fixed по кнопке: .table-card прокручивается вбок
|
||||
// и обрезал бы меню у нижних строк. full — в меню ключа и «Скопировать ссылку» (в таблице;
|
||||
// в карточке на телефоне ссылка — отдельной кнопкой).
|
||||
const [menu, setMenu] = useState<{ kind: 'key' | 'device'; id: number; full: boolean; top: number; right: number; up: boolean } | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [limitEdit, setLimitEdit] = useState('');
|
||||
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
|
||||
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
|
||||
const [creating, setCreating] = useState(false);
|
||||
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
|
||||
const creatingRef = useRef(false);
|
||||
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 seq = ++loadSeq.current;
|
||||
try {
|
||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
if (seq !== loadSeq.current) return;
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
|
||||
const live = list.filter(k => !k.deleting);
|
||||
const res = await Promise.allSettled(live.map(k =>
|
||||
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token)));
|
||||
if (seq !== loadSeq.current) return;
|
||||
const bad = new Set<number>(), unreachable: Record<number, number[]> = {};
|
||||
const fresh: Record<number, Device[]> = {};
|
||||
res.forEach((r, i) => {
|
||||
const id = live[i].id;
|
||||
if (r.status === 'fulfilled') {
|
||||
fresh[id] = r.value.devices ?? [];
|
||||
if (r.value.unreachable?.length) unreachable[id] = r.value.unreachable;
|
||||
} else bad.add(id);
|
||||
});
|
||||
// Не загрузилось — оставляем прежний список (устаревший лучше пустого), но помечаем.
|
||||
setDevs(prev => Object.fromEntries(live.flatMap(k =>
|
||||
fresh[k.id] ? [[k.id, fresh[k.id]]] : prev[k.id] ? [[k.id, prev[k.id]]] : [])));
|
||||
setFailed(bad); setDown(unreachable);
|
||||
} catch { if (seq === loadSeq.current) showMsg('Ошибка загрузки мастер-ключей'); }
|
||||
}, [token, showMsg]);
|
||||
|
||||
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
|
||||
const act = useCallback(async (fn: () => Promise<unknown>, ok: string) => {
|
||||
try { await fn(); showMsg(ok); await load(); }
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
}, [load, showMsg]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!label.trim() || creatingRef.current) return;
|
||||
creatingRef.current = true; setCreating(true);
|
||||
try { await act(async () => {
|
||||
// Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми,
|
||||
// которые уже подключались; набор потом правится в настройках ключа.
|
||||
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
||||
await apiFetch('POST', '/ui/masterkeys', token, {
|
||||
label: label.trim(), device_limit: Number(limit), ...(!defaults && ready.length ? { servers: ready } : {}),
|
||||
});
|
||||
setLabel('');
|
||||
}, 'Мастер-ключ создан'); }
|
||||
finally { creatingRef.current = false; setCreating(false); }
|
||||
}, [label, limit, nodes, defaults, token, act]);
|
||||
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiFetch('GET', '/api/masking', token).then(() => { if (alive) setMasking('ready'); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [token]);
|
||||
|
||||
// Новый случайный I1 на сервере из шапки. Интерфейс не трогается: I1 нужен только клиентам,
|
||||
// sen:// подхватят его со следующим обновлением профиля (≤ 60 с), выданные vpn:// остаются со старым.
|
||||
const regenMasking = async () => {
|
||||
setMasking('busy');
|
||||
try { await apiFetch('POST', '/api/masking', token); showMsg('Маскировка сменена'); }
|
||||
catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось сменить маскировку'); }
|
||||
finally { setMasking('ready'); }
|
||||
};
|
||||
|
||||
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
|
||||
|
||||
const remove = useCallback(async (k: MasterKey) => {
|
||||
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
|
||||
try {
|
||||
const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token);
|
||||
const pending: number[] = r.pending ?? [];
|
||||
showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label);
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [token, load, showMsg, serverNames]);
|
||||
|
||||
const fetchLink = useCallback(async (k: MasterKey) => {
|
||||
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
|
||||
return r as { link: string; tls: boolean };
|
||||
}, [token]);
|
||||
|
||||
const showQR = useCallback(async (k: MasterKey) => {
|
||||
try {
|
||||
const { link, tls: t } = await fetchLink(k);
|
||||
setModal({ label: k.label, link, tls: t, dataUrl: qrDataUrl(link, 'H') });
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [fetchLink, showMsg]);
|
||||
|
||||
const copyLink = useCallback(async (k: MasterKey) => {
|
||||
try { await copyText((await fetchLink(k)).link); showMsg('Ссылка скопирована'); }
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
}, [fetchLink, showMsg]);
|
||||
|
||||
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
|
||||
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
|
||||
// счётчику ключа и имён не называем, но спросить всё равно надо.
|
||||
const saveLimit = useCallback(async (k: MasterKey) => {
|
||||
const limit = Number(limitEdit);
|
||||
const list = devs[k.id];
|
||||
const have = list?.length ?? k.devices;
|
||||
const extra = have - limit;
|
||||
if (Number.isInteger(limit) && extra > 0) {
|
||||
const who = list
|
||||
? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
|
||||
.map(d => `«${d.device_name || d.id}»`).join(', ')
|
||||
: `${extra} шт.`;
|
||||
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
|
||||
}
|
||||
try {
|
||||
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
|
||||
const n: number = r.unbound?.length ?? 0;
|
||||
showMsg(n ? `Лимит сохранён, отвязано устройств: ${n}` : 'Лимит сохранён');
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [limitEdit, devs, token, load, showMsg]);
|
||||
|
||||
const fold = (id: number) => setFolded(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
const openTools = (k: MasterKey) => { setLimitEdit(String(k.device_limit)); setTools(tools === k.id ? null : k.id); };
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
// Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они.
|
||||
const waiting = keys.some(k => k.deleting);
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const t = setInterval(load, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [waiting, load]);
|
||||
|
||||
const deletingChip = (k: MasterKey) => (
|
||||
<span className="chip chip--offline" title="Ключ уже не работает; строка исчезнет, когда серверы подтвердят, что сняли его устройства">
|
||||
<span className="chip-dot" />Удаляется — ждёт: {serverNames(k.deleting ?? [])}
|
||||
</span>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
|
||||
const close = () => setMenu(null);
|
||||
const down = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) close(); };
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { close(); document.querySelector<HTMLButtonElement>(`[data-menu-for="${menu.kind}-${menu.id}"]`)?.focus(); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const items = [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])];
|
||||
const i = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
items[(i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length]?.focus();
|
||||
};
|
||||
document.addEventListener('mousedown', down);
|
||||
document.addEventListener('keydown', key);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', down);
|
||||
document.removeEventListener('keydown', key);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
const openMenu = (kind: 'key' | 'device', id: number, el: HTMLElement, full = false) => {
|
||||
if (menu?.kind === kind && menu.id === id) return setMenu(null);
|
||||
const r = el.getBoundingClientRect();
|
||||
const up = r.bottom + 160 > window.innerHeight; // у нижнего края — вверх от кнопки
|
||||
setMenu({ kind, id, full, top: up ? r.top - 4 : r.bottom + 4, right: window.innerWidth - r.right, up });
|
||||
};
|
||||
const moreButton = (kind: 'key' | 'device', id: number, label: string, full = false) => (
|
||||
<button className="btn-icon mk-more" aria-label={label} aria-haspopup="menu" aria-expanded={menu?.kind === kind && menu.id === id}
|
||||
data-menu-for={`${kind}-${id}`} onClick={e => openMenu(kind, id, e.currentTarget, full)}><IcoMore /></button>
|
||||
);
|
||||
|
||||
// В таблице — QR и «⋮» (остальное в меню), чтобы колонка действий всегда помещалась.
|
||||
// Подсказки — .tip-wrap, не title: нативная появляется с задержкой и бесполезна на тач-экране.
|
||||
const actions = (k: MasterKey) => (
|
||||
<div className="actions">
|
||||
<span className="tip-wrap tip-wrap--end" data-tip="QR-код"><button className="btn-icon" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button></span>
|
||||
{moreButton('key', k.id, `Ещё: ${k.label}`, true)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolbox = (k: MasterKey) => (
|
||||
<div className="mk-tools">
|
||||
{/* Слева — настройки самого ключа */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Настройки ключа</h4>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">UUID</span>
|
||||
<code className="mk-uuid" title={k.uuid}>{k.uuid}</code>
|
||||
<span className="mk-setting-hint">Постоянный: перевыпуск ссылки его не меняет</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
|
||||
<span className="mk-setting-hint">Сколько устройств может подключить ключ</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
|
||||
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
|
||||
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">Ссылка</span>
|
||||
<span className="mk-setting-hint">Старая перестанет принимать новые устройства</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() => {
|
||||
if (confirm('Выпустить новую ссылку? Старая перестанет принимать новые устройства, уже добавленные продолжат работать.'))
|
||||
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rotate`, token), 'Ссылка перевыпущена');
|
||||
}}>Перевыпустить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">Ключи устройств</span>
|
||||
<span className="mk-setting-hint">Клиенты сменят их при следующем опросе</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rekey`, token), 'Смена ключей запрошена')}>
|
||||
Запросить смену
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
|
||||
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Серверы ключа</h4>
|
||||
<ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
|
||||
added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
|
||||
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
|
||||
const meter = (k: MasterKey, n: number) => (
|
||||
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : ''}`}
|
||||
role="progressbar" aria-label="Устройства" aria-valuemin={0}
|
||||
aria-valuemax={k.device_limit} aria-valuenow={n}
|
||||
title={`Устройств: ${n} из ${k.device_limit}`}>
|
||||
<span className="mk-meter-text"><span>{n}</span><span>{k.device_limit}</span></span>
|
||||
<div className="mk-meter-fill"
|
||||
style={{ width: `${Math.min(100, (n / Math.max(1, k.device_limit)) * 100)}%` }}>
|
||||
{/* тот же текст поверх заливки — светлый, чтобы читался на ней */}
|
||||
<span className="mk-meter-text mk-meter-text--on" aria-hidden="true"><span>{n}</span><span>{k.device_limit}</span></span>
|
||||
</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) => (
|
||||
<div className="actions">
|
||||
<span className="tip-wrap tip-wrap--end" data-tip="Новый PSK"><button className="btn-icon" aria-label="Новый PSK" onClick={() => newPsk(d)}><IcoRefresh /></button></span>
|
||||
<span className="tip-wrap tip-wrap--end" data-tip="Отозвать устройство"><button className="btn-icon btn-icon--danger" aria-label="Отозвать устройство" onClick={() => revoke(d)}><IcoTrash /></button></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Короткий ID устройства: полностью — в подсказке, по клику копируется
|
||||
const deviceId = (d: Device) => (
|
||||
<span className="tip-wrap tip-wrap--start" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
|
||||
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
|
||||
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
{d.device_id.slice(0, 8)}…
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
|
||||
// по клику копируется.
|
||||
const nameCell = (k: MasterKey) => (
|
||||
<span className="name-stack">
|
||||
{k.label}
|
||||
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
||||
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
|
||||
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
{k.uuid.slice(0, 8)}…
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
|
||||
const dataFlag = (k: MasterKey) => {
|
||||
if (failed.has(k.id)) return <span className="mk-flag" title="Список устройств не загрузился — показан последний известный"> · устройства не загрузились</span>;
|
||||
const d = down[k.id];
|
||||
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
|
||||
};
|
||||
|
||||
// Серверы ключа, куда ещё не добавлено устройство: был не в сети, когда оно подключалось или меняло
|
||||
// ключ; панель добавит его сама, когда сервер появится (reconcile). Без ips (старая панель) — неизвестно.
|
||||
const missingOn = (k: MasterKey, d: Device) => d.ips ? k.servers.filter(s => !d.ips!.some(x => x.server_id === s)) : [];
|
||||
// «из 3 серверов», «из 21 сервера»
|
||||
const ofServers = (n: number) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'сервера' : 'серверов'}`;
|
||||
const syncFlag = (k: MasterKey, d: Device) => {
|
||||
const miss = missingOn(k, d);
|
||||
if (miss.length) return `${k.servers.length - miss.length} из ${ofServers(k.servers.length)}`;
|
||||
return d.servers_ok < d.servers_total ? `${d.servers_ok} из ${ofServers(d.servers_total)}` : '';
|
||||
};
|
||||
const syncTip = (k: MasterKey, d: Device) => {
|
||||
const miss = missingOn(k, d);
|
||||
return miss.length ? `Нет на ${miss.map(s => serverName(nodes, s)).join(', ')} — добавится само, когда сервер будет на связи`
|
||||
: 'Не на всех серверах ключа — добавится само, когда они будут на связи';
|
||||
};
|
||||
|
||||
// Серверы ключа — в его строке под колонками IP и «Платформа»: «все серверы» (у ключа с одним
|
||||
// сервером — его имя) светло-зелёным или «2 из 3 серверов» тёплым, если на каком-то ещё нет всех
|
||||
// устройств ключа.
|
||||
// Имена — в подсказке.
|
||||
const keyServers = (k: MasterKey, list: Device[]) => {
|
||||
const behind = (s: number) => list.some(d => missingOn(k, d).includes(s));
|
||||
const late = k.servers.filter(behind);
|
||||
const ready = k.servers.filter(s => !behind(s));
|
||||
const names = (ids: number[]) => ids.map(s => serverName(nodes, s)).join(', ');
|
||||
const text = late.length ? `${ready.length} из ${ofServers(k.servers.length)}`
|
||||
: k.servers.length === 1 ? serverName(nodes, k.servers[0]) : 'все серверы';
|
||||
const tip = late.length ? `Есть: ${names(ready) || 'нигде'}. Нет: ${names(late)} — добавятся сами, когда сервер будет на связи`
|
||||
: k.servers.length > 1 ? names(k.servers) : null;
|
||||
const chip = <span className={`chip ${late.length ? 'chip--warm' : 'chip--online'} mk-srv`}>{text}</span>; // всё на месте — светло-зелёный
|
||||
return tip ? <span className="tip-wrap tip-wrap--end" data-tip={tip}>{chip}</span> : chip;
|
||||
};
|
||||
|
||||
// Статус ключа по его устройствам: онлайн / когда были / «нет устройств», плюс фоновый приоритет и торренты
|
||||
const keyStatus = (list: Device[], empty: boolean) => {
|
||||
if (!list.length) return empty ? <span className="mk-flag">нет устройств</span> : null;
|
||||
const on = list.filter(d => d.online).length;
|
||||
const last = Math.max(...list.map(d => d.lastHandshake || 0));
|
||||
const torrent = list.some(d => d.torrent);
|
||||
const prio = bgOnly(list.some(d => d.priority === 'background') ? 'background' : undefined);
|
||||
return (
|
||||
<>
|
||||
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
|
||||
</span>
|
||||
<PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
|
||||
const summary = (k: MasterKey, list: Device[], empty: boolean) => {
|
||||
if (!list.length) return <><td>{keyStatus(list, empty)}</td><td colSpan={2}>{keyServers(k, list)}</td><td></td><td></td></>;
|
||||
return (
|
||||
<>
|
||||
<td><span className="user-chips">{keyStatus(list, empty)}</span></td>
|
||||
<td colSpan={2}>{keyServers(k, list)}</td>
|
||||
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
|
||||
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Один адрес — выбранного в шапке сервера, остальные — «+N» и подсказка со всеми. Адрес с другого
|
||||
// сервера подписан его именем строкой ниже, иначе его примут за адрес выбранного.
|
||||
const ipCell = (d: Device) => {
|
||||
const all = d.ips ?? [];
|
||||
const { ip, serverId, other } = ipPick(d);
|
||||
const body = (
|
||||
<span className="name-stack">
|
||||
<span className="mk-ip-line">
|
||||
<code className="mk-ip">{ip}</code>
|
||||
{all.length > 1 && <span className="mk-ip-more">+{all.length - 1}</span>}
|
||||
</span>
|
||||
{other && serverId !== undefined && <span className="mk-sub">{serverName(nodes, serverId)}</span>}
|
||||
</span>
|
||||
);
|
||||
return all.length > 1
|
||||
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{body}</span>
|
||||
: body;
|
||||
};
|
||||
|
||||
// Самая новая версия SenAWG среди устройств панели: кто отстал — тёплой плашкой, как ноды в меню серверов
|
||||
const newest = useMemo(() => Object.values(devs).flat().map(d => d.version).filter(Boolean)
|
||||
.reduce<string | null>((a, v) => !a || cmpVersion(v, a) > 0 ? v : a, null), [devs]);
|
||||
const versionTag = (d: Device, tip: 'start' | 'end') => {
|
||||
if (!d.version) return null;
|
||||
const old = !!newest && cmpVersion(d.version, newest) < 0;
|
||||
return old
|
||||
? <span className={`tip-wrap tip-wrap--${tip}`} data-tip={`Есть новее: ${newest} — обнови SenAWG на устройстве`}><span className="chip chip--warm mk-ver">{d.version}</span></span>
|
||||
: <span className="mk-sub">{d.version}</span>;
|
||||
};
|
||||
const platformCell = (d: Device) => (
|
||||
<span className="name-stack">
|
||||
{d.platform || '—'}
|
||||
{versionTag(d, 'end')}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Раскрывать нечего — вместо стрелки пустое место той же ширины, имена остаются в одну линию
|
||||
const caret = (k: MasterKey, n: number) => n === 0 ? <span className="mk-caret-gap" aria-hidden="true" /> : (
|
||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||
onClick={() => fold(k.id)}>▾</button>
|
||||
);
|
||||
|
||||
// Телефон: устройство — строка списка в карточке ключа, а не карточка в карточке. Имя, статус и «⋮»;
|
||||
// ниже мелко — платформа, версия, IP; трафик, приоритет и то, на скольких серверах оно есть.
|
||||
const deviceRow = (k: MasterKey, d: Device) => {
|
||||
const all = d.ips ?? [];
|
||||
const { ip, serverId, other } = ipPick(d);
|
||||
return (
|
||||
<li className="mk-dev" key={d.id}>
|
||||
<div className="mk-dev-head">
|
||||
<span className="name-stack mk-dev-title">
|
||||
<span className="mk-dev-name">{d.device_name || 'Устройство'}</span>
|
||||
{deviceId(d)}
|
||||
</span>
|
||||
{statusChip(d)}
|
||||
{moreButton('device', d.id, `Ещё: ${d.device_name || 'устройство'}`)}
|
||||
</div>
|
||||
<div className="mk-dev-meta">
|
||||
<span>{d.platform || '—'}</span>
|
||||
{versionTag(d, 'start')}
|
||||
<span aria-hidden="true">·</span>
|
||||
<code className="mk-ip">{ip}</code>
|
||||
{all.length > 1 && <span className="mk-ip-more" aria-label={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(', ')}>+{all.length - 1}</span>}
|
||||
{other && serverId !== undefined && <span>{serverName(nodes, serverId)}</span>}
|
||||
</div>
|
||||
<div className="mk-dev-meta">
|
||||
<span className="mk-dev-traffic">↓ {bytes(d.rx)} ↑ {bytes(d.tx)}</span>
|
||||
<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} />
|
||||
<TorrentChip torrent={d.torrent} />
|
||||
{syncFlag(k, d) && <span>{syncFlag(k, d)}</span>}
|
||||
{d.rekey_requested && <span>ждёт смены ключа</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!enabled && (
|
||||
<p className="mk-notice">Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.</p>
|
||||
)}
|
||||
{enabled && !tls && (
|
||||
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
|
||||
)}
|
||||
|
||||
{masking && (
|
||||
<div className="mk-warn" role="note">
|
||||
<span className="mk-warn-text">
|
||||
<b>Маскировка первого пакета · {serverName(nodes, getServerId() ?? 0)}</b>
|
||||
Перед подключением клиент шлёт пакет, похожий на обычный трафик. Новый шаблон устройства sen://
|
||||
получат в течение минуты.
|
||||
</span>
|
||||
<button className="btn btn--tonal" onClick={regenMasking} disabled={masking === 'busy'}>
|
||||
<IcoRefresh /> Сменить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toolbar">
|
||||
<input className="field" placeholder="Метка (напр. Семья)" value={label}
|
||||
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
|
||||
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
|
||||
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
|
||||
<button className="btn btn--tonal" onClick={load}><IcoRefresh /> Обновить</button>
|
||||
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
|
||||
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */}
|
||||
<div className="table-card mk-table-card">
|
||||
<table className="mk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th><th>Статус</th><th>IP</th><th>Платформа</th>
|
||||
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={8} className="empty">Нет мастер-ключей</td></tr>
|
||||
) : keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<tr key={k.id} className="mk-tr-master mk-last">
|
||||
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
|
||||
<td colSpan={7}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const count = devs[k.id]?.length ?? k.devices;
|
||||
const open = !folded.has(k.id);
|
||||
return (
|
||||
<Fragment key={k.id}>
|
||||
<tr className="mk-tr-master">
|
||||
<td><span className="mk-name">{caret(k, count)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
|
||||
{summary(k, list, count === 0)}
|
||||
<td>{meter(k, count)}</td>
|
||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={8}>{toolbox(k)}</td></tr>}
|
||||
{open && list.map((d, i) => (
|
||||
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 ? ' mk-last' : ''}`}>
|
||||
<td>
|
||||
<span className="mk-indent">
|
||||
<span className="name-stack">
|
||||
{d.device_name || 'Устройство'}
|
||||
{/* пометки — в строке ID, не рядом с именем: так колонка «Имя» уже и таблица влезает */}
|
||||
<span className="mk-id-line">
|
||||
{deviceId(d)}
|
||||
{syncFlag(k, d) && <span className="tip-wrap tip-wrap--start mk-flag" data-tip={syncTip(k, d)}>· {syncFlag(k, d)}</span>}
|
||||
{d.rekey_requested && <span className="mk-flag">· ждёт смены ключа</span>}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="user-chips">{statusChip(d)}<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
|
||||
<td>{ipCell(d)}</td>
|
||||
<td>{platformCell(d)}</td>
|
||||
<td>{bytes(d.rx)}</td>
|
||||
<td>{bytes(d.tx)}</td>
|
||||
<td></td>
|
||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Телефон и планшет: таблица скрыта (.table-card) — карточка на ключ: шапка, сводка, действия,
|
||||
ниже списком его устройства */}
|
||||
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
||||
<ul className="mk-tree">
|
||||
{keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{nameCell(k)}</span>
|
||||
</div>
|
||||
<div className="mk-master-sum">{deletingChip(k)}</div>
|
||||
</li>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const count = devs[k.id]?.length ?? k.devices;
|
||||
const isFolded = folded.has(k.id);
|
||||
return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
{caret(k, count)}
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{nameCell(k)}</span>
|
||||
{meter(k, count)}
|
||||
</div>
|
||||
<div className="mk-master-sum">
|
||||
{keyStatus(list, count === 0)}
|
||||
{keyServers(k, list)}
|
||||
{dataFlag(k)}
|
||||
</div>
|
||||
<div className="mk-master-actions">
|
||||
<button className="btn btn--tonal" onClick={() => showQR(k)}><IcoQR /> QR</button>
|
||||
<button className="btn btn--outline" onClick={() => copyLink(k)}><IcoCopy /> Ссылка</button>
|
||||
{moreButton('key', k.id, `Ещё: ${k.label}`)}
|
||||
</div>
|
||||
{tools === k.id && toolbox(k)}
|
||||
{!isFolded && list.length > 0 && <ul className="mk-devs">{list.map(d => deviceRow(k, d))}</ul>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{menu && (() => {
|
||||
const run = (f: () => void) => () => { setMenu(null); f(); };
|
||||
const at = { top: menu.top, right: menu.right };
|
||||
const cls = `mk-menu${menu.up ? ' mk-menu--up' : ''}`;
|
||||
if (menu.kind === 'device') {
|
||||
const d = Object.values(devs).flat().find(x => x.id === menu.id);
|
||||
if (!d) return null;
|
||||
return (
|
||||
<div className={cls} role="menu" aria-label={`Устройство ${d.device_name}`} ref={menuRef} style={at}>
|
||||
<button role="menuitem" className="mk-menu-item" onClick={run(() => newPsk(d))}><IcoRefresh /> Новый PSK</button>
|
||||
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => revoke(d))}><IcoTrash /> Отозвать</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const k = keys.find(x => x.id === menu.id);
|
||||
if (!k) return null;
|
||||
return (
|
||||
<div className={cls} role="menu" aria-label={`Мастер-ключ ${k.label}`} ref={menuRef} style={at}>
|
||||
{menu.full && <button role="menuitem" className="mk-menu-item" onClick={run(() => copyLink(k))}><IcoCopy /> Скопировать ссылку</button>}
|
||||
<button role="menuitem" className="mk-menu-item" onClick={run(() => openTools(k))}><IcoSettings /> Настройки ключа</button>
|
||||
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => remove(k))}><IcoTrash /> Удалить</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{bulk && (
|
||||
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
|
||||
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<div className="qr-backdrop" onClick={() => setModal(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">{modal.label}</p>
|
||||
<img className="qr-img" src={modal.dataUrl} alt="QR" title="Нажми чтобы скопировать sen:// ссылку"
|
||||
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
|
||||
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--text" onClick={() => setModal(null)}>Закрыть</button>
|
||||
<button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
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. */
|
||||
|
||||
.mk-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;
|
||||
}
|
||||
/* Предупреждение о маскировке: тёплый tertiary, как чип «устарела» у версии сервера */
|
||||
.mk-warn {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--tertiary-container); color: var(--on-tertiary-container);
|
||||
border-radius: var(--shape-large);
|
||||
padding: 10px 12px 10px 16px;
|
||||
}
|
||||
.mk-warn-text {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
|
||||
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||
}
|
||||
.mk-warn-text b { font-weight: var(--weight-medium); }
|
||||
.mk-warn .btn { flex-shrink: 0; }
|
||||
.mk-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
|
||||
.mk-limit-input { width: 88px !important; }
|
||||
|
||||
/* ── таблица (десктоп) ── */
|
||||
.mk-table td, .mk-table th { white-space: nowrap; }
|
||||
.mk-table .td-actions { width: 80px; } /* QR и «⋮» */
|
||||
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
|
||||
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
|
||||
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
|
||||
.mk-tr-master td { background: var(--surface-container); font-weight: var(--weight-medium); padding-top: 6px; padding-bottom: 6px; }
|
||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||
.mk-name .mk-caret { margin-left: -8px; }
|
||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
||||
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
|
||||
.mk-tr-tools:hover { background: transparent; }
|
||||
.mk-tr-device td { padding-top: 10px; padding-bottom: 10px; }
|
||||
.mk-flag { color: var(--neutral-text); font-size: var(--body-small-size); line-height: var(--body-small-line); }
|
||||
.mk-id-line { display: inline-flex; align-items: baseline; gap: 6px; }
|
||||
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
|
||||
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
|
||||
.mk-ip { font-family: var(--mono); 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 {
|
||||
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
|
||||
}
|
||||
.mk-tr-device.mk-last td:first-child { background-size: 2px 50%; }
|
||||
.mk-indent { position: relative; display: inline-block; padding-left: 36px; }
|
||||
.mk-indent::before {
|
||||
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
|
||||
border-top: 2px solid var(--outline);
|
||||
}
|
||||
|
||||
/* ── дерево (мобильный) ── */
|
||||
.mk-tree, .mk-devs { list-style: none; margin: 0; padding: 0; }
|
||||
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
|
||||
|
||||
.mk-root {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--shape-extra-large);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* шапка ключа: стрелка, ключ, метка с коротким UUID, полоска устройств */
|
||||
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
|
||||
.mk-master-label {
|
||||
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--on-surface);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mk-caret {
|
||||
width: 28px; height: 28px;
|
||||
border: 0; border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--neutral-text);
|
||||
font-size: var(--body-large-size); line-height: var(--body-large-line);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--duration-short3) var(--ease-standard);
|
||||
}
|
||||
.mk-caret--folded { transform: rotate(-90deg); }
|
||||
.mk-caret-gap { width: 28px; flex-shrink: 0; } /* ключ без устройств: место стрелки */
|
||||
.mk-name .mk-caret-gap { margin-left: -8px; }
|
||||
|
||||
/* Меню «⋮» ключа (MD3 menu): плавает над таблицей, у кнопки справа */
|
||||
.mk-menu {
|
||||
position: fixed; z-index: 30;
|
||||
min-width: 200px; padding: 6px;
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--shape-large);
|
||||
box-shadow: var(--elevation-2);
|
||||
animation: mk-menu-in var(--duration-short4) var(--ease-emphasized-decelerate);
|
||||
transform-origin: top right;
|
||||
}
|
||||
.mk-menu--up { transform: translateY(-100%); transform-origin: bottom right; }
|
||||
@keyframes mk-menu-in { from { opacity: 0; scale: .92; } }
|
||||
.mk-menu-item {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
min-height: 40px; padding: 0 12px;
|
||||
border: 0; border-radius: var(--shape-medium); background: transparent;
|
||||
font-family: var(--font); font-size: var(--label-large-size); line-height: var(--label-large-line); font-weight: var(--weight-medium);
|
||||
color: var(--on-surface); text-align: left; cursor: pointer;
|
||||
}
|
||||
.mk-menu-item svg { color: var(--on-surface-variant); flex-shrink: 0; }
|
||||
.mk-menu-item--danger, .mk-menu-item--danger svg { color: var(--error); }
|
||||
@media (pointer: coarse) { .mk-menu-item { min-height: 48px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .mk-menu { animation: none; } }
|
||||
|
||||
.mk-tools {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
/* два одинаковых блока: слева настройки ключа, справа серверы */
|
||||
.mk-box {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--shape-large);
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
.mk-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
|
||||
.mk-box .mk-box-title { margin-bottom: 2px; }
|
||||
/* строка настройки: слева название и пояснение, справа контрол */
|
||||
.mk-setting {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
}
|
||||
.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
|
||||
.mk-setting:last-child { padding-bottom: 0; }
|
||||
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.mk-setting-name { font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface); }
|
||||
.mk-setting-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.mk-uuid {
|
||||
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
|
||||
}
|
||||
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: var(--label-large-size); line-height: var(--label-large-line); }
|
||||
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||
|
||||
/* прогресс занятых слотов с числом внутри: обычная заливка primary, цветом — только когда мест
|
||||
не осталось (тёплый: это повод присмотреться, а не ошибка).
|
||||
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
|
||||
.mk-meter {
|
||||
--mk-w: 136px;
|
||||
--meter-fill: var(--primary);
|
||||
--meter-on: var(--on-primary); /* цвет числа поверх заливки */
|
||||
position: relative;
|
||||
width: var(--mk-w); height: 20px;
|
||||
border-radius: var(--shape-full);
|
||||
background: var(--outline-variant);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mk-meter-fill {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
background: var(--meter-fill);
|
||||
overflow: hidden;
|
||||
transition: width var(--duration-medium1) var(--ease-standard), background var(--duration-medium1) var(--ease-standard);
|
||||
}
|
||||
/* числа по краям внутри полоски: текущее слева, лимит справа */
|
||||
.mk-meter-text {
|
||||
position: absolute; left: 0; top: 0;
|
||||
width: var(--mk-w); height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 11px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: var(--label-medium-size); line-height: var(--label-medium-line); font-weight: var(--weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.mk-meter-text--on { color: var(--meter-on); }
|
||||
.mk-meter--full { --meter-fill: var(--warn-dot); --meter-on: #2a2118; } /* все места заняты */
|
||||
|
||||
.mk-box .tip-wrap { width: 100%; }
|
||||
|
||||
/* сводка (статус, серверы) и действия ключа */
|
||||
.mk-master-sum { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
|
||||
.mk-master-sum:empty { display: none; }
|
||||
.mk-master-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.mk-master-actions .btn { flex: 1; }
|
||||
.mk-tree .mk-more { width: 40px; height: 40px; }
|
||||
/* на телефоне .tip-wrap по умолчанию во всю ширину — здесь чипы и ID идут в строку */
|
||||
.mk-tree .tip-wrap { width: auto; }
|
||||
|
||||
/* устройства ключа — строками списка под ним, разделены линией */
|
||||
.mk-devs { margin: 12px -16px -12px; border-top: 1px solid var(--outline-variant); }
|
||||
.mk-dev { padding: 10px 16px; }
|
||||
.mk-dev + .mk-dev { border-top: 1px solid var(--outline-variant); }
|
||||
.mk-dev-head { display: flex; align-items: center; gap: 8px; }
|
||||
.mk-dev-title { flex: 1; min-width: 0; }
|
||||
.mk-dev-name {
|
||||
font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mk-dev-head .mk-more { margin: -4px -8px -4px 0; }
|
||||
.mk-dev-meta {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px;
|
||||
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
|
||||
}
|
||||
.mk-dev-meta .chip { font-size: var(--body-small-size); }
|
||||
.mk-dev-traffic { font-family: var(--mono); white-space: pre; }
|
||||
|
||||
/* ── телефон и планшет (design.md, часть IV): карточка на ключ, устройства строками, тач-цели ≥ 44px ── */
|
||||
/* compact и medium (< 840px): карточки вместо таблицы */
|
||||
@media (max-width: 839px) {
|
||||
.mk-warn { flex-wrap: wrap; padding: 12px 12px 12px 16px; }
|
||||
.mk-warn-text { flex-basis: 100%; }
|
||||
.mk-warn .btn { margin-left: auto; }
|
||||
.mk-tree { display: flex; }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.mk-tree .mk-more { width: 44px; height: 44px; }
|
||||
}
|
||||
@media (max-width: 599px) {
|
||||
.mk-limit-input { width: 100% !important; }
|
||||
|
||||
.mk-root { padding: 12px; }
|
||||
.mk-devs { margin: 12px -12px -12px; }
|
||||
.mk-dev { padding: 10px 12px; }
|
||||
|
||||
.mk-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-tools { grid-template-columns: minmax(0, 1fr); margin: 8px 0 4px; }
|
||||
.mk-setting { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||
.mk-setting-ctl { width: 100%; }
|
||||
.mk-setting-ctl .btn { flex: 1; height: 44px; }
|
||||
.mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; }
|
||||
.mk-tools .btn { width: 100%; height: 44px; justify-content: center; }
|
||||
}
|
||||
|
||||
/* «Серверы для всех» (BulkServersModal) */
|
||||
.bs-modal { max-width: 620px; }
|
||||
.bs-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.bs-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface-container); border-radius: var(--shape-medium); padding: 8px 8px 8px 14px;
|
||||
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
|
||||
}
|
||||
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.bs-sub .server-pick-ip { margin-left: 0; }
|
||||
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.bs-opt {
|
||||
min-height: 36px; padding: 0 12px;
|
||||
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline); border-radius: var(--shape-full);
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
|
||||
.bs-check { display: flex; align-items: center; gap: 8px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); cursor: pointer; }
|
||||
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
|
||||
@media (max-width: 599px) {
|
||||
.bs-row { flex-wrap: wrap; }
|
||||
.bs-opts { width: 100%; }
|
||||
.bs-opt { flex: 1; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "masterkeys",
|
||||
"label": "Мастер-ключи",
|
||||
"icon": "key",
|
||||
"group": "main",
|
||||
"order": 1.5
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// 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, getServerId, openTab, announcePrometheus, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
import { IcoGear } from '../../components/icons';
|
||||
import type { MonitorStatus as Status } from '../settings/prometheus';
|
||||
import LineChart from '../../components/LineChart';
|
||||
import ServerSelect, { ALL_SERVERS } from '../../components/ServerSelect';
|
||||
import './monitor.css';
|
||||
|
||||
// Вкладка «Монитор»: основные графики со встроенного Prometheus. Ставится и удаляется он в «Настройках»,
|
||||
// раздел «Prometheus» (tabs/settings/prometheus); пока его нет, здесь — карточка со ссылкой туда.
|
||||
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
|
||||
type Pts = [number, number][];
|
||||
interface Overview {
|
||||
range: string; step: number; from: number; to: number;
|
||||
servers: {
|
||||
id: number; name: string; ok: boolean; down: number | null; up: number | null; online: number | null; heavy: number | null; congested: boolean;
|
||||
cpu: number | null; mem: number | null; cores: number | null; mem_total: number | null;
|
||||
used_down: number | null; used_up: number | null; // байт за весь период
|
||||
}[];
|
||||
total: Series;
|
||||
}
|
||||
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
|
||||
type Row = Overview['servers'][number];
|
||||
type NumKey = 'down' | 'up' | 'online' | 'heavy' | 'cpu' | 'mem' | 'cores' | 'mem_total' | 'used_down' | 'used_up';
|
||||
|
||||
// «Все серверы» — сводка «сейчас» для плиток: скорости, устройства, ядра, память и объём складываем,
|
||||
// загрузку процессора и памяти — среднее по серверам (как и ряды total в monitor.go).
|
||||
function sumUp(rows: Row[]): Row | undefined {
|
||||
if (rows.length <= 1) return rows[0];
|
||||
const sum = (k: NumKey) => rows.some(r => r[k] != null) ? rows.reduce((n, r) => n + (r[k] ?? 0), 0) : null;
|
||||
const avg = (k: NumKey) => {
|
||||
const v = rows.map(r => r[k]).filter((x): x is number => x != null);
|
||||
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
|
||||
};
|
||||
return {
|
||||
id: ALL_SERVERS, name: 'Все серверы', ok: rows.every(r => r.ok), congested: rows.some(r => r.congested),
|
||||
down: sum('down'), up: sum('up'), online: sum('online'), heavy: sum('heavy'), cpu: avg('cpu'), mem: avg('mem'),
|
||||
cores: sum('cores'), mem_total: sum('mem_total'), used_down: sum('used_down'), used_up: sum('used_up'),
|
||||
};
|
||||
}
|
||||
|
||||
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
|
||||
|
||||
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
|
||||
// Скорость в битах/с → «12,4 Мбит/с»
|
||||
function rate(v: number, d = 1): string {
|
||||
if (v >= 1e9) return `${nf(v / 1e9, d)} Гбит/с`;
|
||||
if (v >= 1e6) return `${nf(v / 1e6, d)} Мбит/с`;
|
||||
if (v >= 1e3) return `${nf(v / 1e3, d)} Кбит/с`;
|
||||
return `${nf(v, 0)} бит/с`;
|
||||
}
|
||||
const lastOf = (p: Pts) => (p.length ? p[p.length - 1][1] : null);
|
||||
// Полоска нагрузки: цвет заливки — серьёзность (норма → внимание → опасно), дорожка — светлая ступень той же шкалы.
|
||||
// Цифра рядом всегда есть: цвет не единственный носитель смысла.
|
||||
const level = (v: number) => (v >= 90 ? 'mon-bar--bad' : v >= 70 ? 'mon-bar--warn' : '');
|
||||
function Bar({ v, label }: { v: number | null | undefined; label: string }) {
|
||||
const pct = v == null ? 0 : Math.min(100, Math.max(0, v));
|
||||
return (
|
||||
<span className="mon-bar" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
||||
aria-valuenow={v == null ? undefined : Math.round(pct)}>
|
||||
<i className={v == null ? '' : level(pct)} style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
|
||||
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
|
||||
function vol(b: number): string {
|
||||
if (b >= 1e12) return `${nf(b / 1e12, 2)} ТБ`;
|
||||
if (b >= 1e9) return `${nf(b / 1e9, 1)} ГБ`;
|
||||
return `${nf(b / 1e6, 0)} МБ`;
|
||||
}
|
||||
|
||||
export default function MonitorPage({ token }: PageProps) {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [range, setRange] = useState('1h');
|
||||
const [ov, setOv] = useState<Overview | null>(null);
|
||||
const [ovErr, setOvErr] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
// Чьи графики: сервер (по умолчанию — выбранный в шапке) или ALL_SERVERS. Выбор свой, шапку не меняет.
|
||||
const [view, setView] = useState(() => getServerId() ?? 0);
|
||||
const alive = useRef(true);
|
||||
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
|
||||
catch { return null; }
|
||||
}, [token]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Без ?server= monitor.go отдаёт все серверы: total — сумма (процессор и память — среднее), servers — каждый
|
||||
const q = view === ALL_SERVERS ? '' : `&server=${view}`;
|
||||
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}${q}`, token) as Overview;
|
||||
if (alive.current) { setOv(o); setOvErr(''); }
|
||||
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
|
||||
finally { if (alive.current) setLoading(false); }
|
||||
}, [token, range, view]);
|
||||
|
||||
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
||||
|
||||
// Серверы для выбора (и их связь — точки на плашках)
|
||||
useEffect(() => {
|
||||
const load = () => apiFetch('GET', '/ui/nodes', token)
|
||||
.then(r => { if (alive.current) setNodes(r.nodes ?? []); })
|
||||
.catch(() => { /* выбор необязателен — останется текущий сервер */ });
|
||||
void load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [token]);
|
||||
|
||||
// Пока Prometheus готов — обновляем графики; пока ставится — следим за ходом установки.
|
||||
useEffect(() => {
|
||||
if (!status?.ready) return;
|
||||
void loadOverview();
|
||||
const t = setInterval(() => { void loadOverview(); }, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [status?.ready, loadOverview]);
|
||||
|
||||
// Пока Prometheus нет — раз в 10 с проверяем: его могли поставить в «Настройках»
|
||||
useEffect(() => {
|
||||
if (!status || status.ready) return;
|
||||
const t = setInterval(() => { void loadStatus(); }, 10000);
|
||||
return () => clearInterval(t);
|
||||
}, [status, loadStatus]);
|
||||
|
||||
// Prometheus нет — графиков не будет: ведём в «Настройки», где он ставится
|
||||
const setup = status && !status.ready && (
|
||||
<section className="mon-card" aria-labelledby="mon-setup">
|
||||
<div className="mon-head">
|
||||
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
|
||||
<span className="chip chip--offline">
|
||||
<span className="chip-dot" />{status.job?.running && status.job.action === 'install' ? 'ставится' : 'не подключён'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mon-hint">
|
||||
Графики — скорость, устройства онлайн, нагрузка на канал — строит встроенный Prometheus.
|
||||
Он ставится в «Настройках», на вкладке «Prometheus»; графики появятся здесь через минуту-две после установки.
|
||||
</p>
|
||||
<div className="mon-actions">
|
||||
<button className="btn btn--primary" onClick={() => openTab('settings', 'prom')}>
|
||||
<IcoGear /> Открыть настройки
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
// ───────── дашборд ─────────
|
||||
// Один сервер или сводка по всем (sumUp) — плитки читают одно и то же
|
||||
const all = view === ALL_SERVERS;
|
||||
const one = all ? sumUp(ov?.servers ?? []) : ov?.servers[0];
|
||||
// Кого нет в сумме: сервер не отдал метрики (ok = false) или на связи, а рядов нет совсем (старый агент)
|
||||
const silent = !all || !ov ? [] : [
|
||||
...ov.servers.filter(r => !r.ok).map(r => r.name),
|
||||
...nodes.filter(n => n.online && !ov.servers.some(r => r.id === n.id)).map(n => n.health?.server || n.name),
|
||||
];
|
||||
const data = ov?.total ?? null;
|
||||
const cur = data && {
|
||||
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
|
||||
down: one?.down ?? lastOf(data.down), up: one?.up ?? lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
|
||||
busy: !!one?.congested,
|
||||
};
|
||||
const used = one?.used_down != null && one.used_up != null ? one.used_down + one.used_up : null;
|
||||
// Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда
|
||||
const peak = data ? Math.max(0, ...data.down.map(p => p[1]), ...data.up.map(p => p[1])) : 0;
|
||||
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
|
||||
const dashboard = status?.ready && (
|
||||
<>
|
||||
{/* Период слева задаёт окно для всего ниже; справа — чьи графики: кнопка и меню, как выбор
|
||||
сервера в шапке, «Все серверы» — сводка по всем */}
|
||||
<div className="mon-toolbar">
|
||||
<div className="mon-filters" role="group" aria-label="Период">
|
||||
{RANGES.map(([id, label]) => (
|
||||
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
|
||||
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{nodes.length > 0 && (
|
||||
<ServerSelect name="mon-server-menu" nodes={nodes} value={view} onChange={setView} all pickOffline />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ovErr && <p className="login-error">{ovErr}</p>}
|
||||
{ov && !all && !one?.ok && (
|
||||
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
|
||||
)}
|
||||
{ov && all && (ov.servers.length === 0 || silent.length > 0) && (
|
||||
<p className="mon-hint mon-notice">
|
||||
{ov.servers.length === 0 ? 'Метрики сейчас не отдаёт ни один сервер.'
|
||||
: `Не отдают метрики: ${silent.join(', ')} — в сумме их нет.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mon-tiles">
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Трафик сейчас</span>
|
||||
{/* Стрелки окрашены как серии графика «Трафик» ниже */}
|
||||
<div className="mon-tile-rates">
|
||||
<span className="mon-tile-value mon-tile-rate">
|
||||
<span className="mon-tile-dir" style={{ color: 'var(--series-1)' }} role="img" aria-label="загрузка">↓</span>
|
||||
{cur?.down != null ? rate(cur.down) : '—'}
|
||||
</span>
|
||||
<span className="mon-tile-value mon-tile-rate">
|
||||
<span className="mon-tile-dir" style={{ color: 'var(--series-2)' }} role="img" aria-label="отдача">↑</span>
|
||||
{cur?.up != null ? rate(cur.up) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mon-tile-value mon-tile-rate mon-tile-total">
|
||||
{used != null ? vol(used) : '—'}
|
||||
<span className="mon-tile-sub">потрачено за {RANGES.find(([id]) => id === range)?.[1]}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Устройства</span>
|
||||
{/* Точки окрашены как серии графика «Устройства» ниже */}
|
||||
<div className="mon-tile-rates">
|
||||
<span className="mon-tile-value mon-tile-rate">
|
||||
<span className="mon-tile-dot" style={{ background: 'var(--series-1)' }} aria-hidden="true" />
|
||||
{cur?.online != null ? nf(cur.online, 0) : '—'}
|
||||
<span className="mon-tile-sub">онлайн</span>
|
||||
</span>
|
||||
<span className="mon-tile-value mon-tile-rate">
|
||||
<span className="mon-tile-dot" style={{ background: 'var(--series-2)' }} aria-hidden="true" />
|
||||
{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}
|
||||
<span className="mon-tile-sub">в фоне · {cur?.busy ? 'канал занят' : 'канал свободен'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mon-tile">
|
||||
<span className="mon-tile-label">Нагрузка</span>
|
||||
<div className="mon-tile-rates">
|
||||
<div className="mon-tile-meter">
|
||||
<span className="mon-tile-value mon-tile-rate">
|
||||
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
|
||||
<span className="mon-tile-sub">процессор{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
|
||||
</span>
|
||||
<Bar v={one?.cpu} label="Загрузка процессора" />
|
||||
</div>
|
||||
<div className="mon-tile-meter">
|
||||
<span className="mon-tile-value mon-tile-rate">
|
||||
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
|
||||
<span className="mon-tile-sub">память{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
|
||||
</span>
|
||||
<Bar v={one?.mem} label="Занято оперативной памяти" />
|
||||
</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 && (
|
||||
<div className="mon-charts">
|
||||
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||
format={v => rate(v)} formatAxis={v => nf(v / unit.div, unit.div > 1 && v / unit.div < 10 ? 1 : 0)}
|
||||
series={[
|
||||
{ id: 'down', name: 'загрузка', color: 'var(--series-1)', points: data.down },
|
||||
{ id: 'up', name: 'отдача', color: 'var(--series-2)', points: data.up },
|
||||
]} />
|
||||
<LineChart title="Процессор и память, %" from={ov.from} to={ov.to} step={ov.step} dim={loading} max={100}
|
||||
format={v => `${nf(v, 0)} %`}
|
||||
series={[
|
||||
{ id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu },
|
||||
{ id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem },
|
||||
]} />
|
||||
<LineChart title="Устройства" from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||
format={v => nf(v, 0)}
|
||||
series={[
|
||||
{ id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online },
|
||||
{ id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy },
|
||||
]} />
|
||||
<LineChart title="Отброшено очередью, пакетов/с" from={ov.from} to={ov.to} step={ov.step} dim={loading}
|
||||
format={v => nf(v, 1)}
|
||||
series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!status && <p className="mon-hint">Загрузка…</p>}
|
||||
{setup}
|
||||
{dashboard}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "monitor",
|
||||
"label": "Монитор",
|
||||
"icon": "pulse",
|
||||
"group": "server",
|
||||
"order": 1.7,
|
||||
"requires": "prometheus",
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
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. */
|
||||
.mon-card {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--shape-extra-large);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.mon-title { margin: 0; font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
|
||||
.mon-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.mon-actions .btn { flex: 1; }
|
||||
.mon-toolbar .server-select { flex: 1 1 100%; } /* своя строка под периодом */
|
||||
.mon-toolbar .server-current { width: 100%; }
|
||||
.mon-toolbar .server-menu { width: 100%; }
|
||||
}
|
||||
|
||||
/* ───── Графики ─────
|
||||
Цвета рядов — токены темы. Проверено validate_palette: светлая #6a45b8 / #c4620a на #fdfaf6,
|
||||
тёмная #8f6be0 / #d9772a на #2b251d (CVD ΔE ≥ 19, контраст ≥ 3:1). Подписи и значения цветом ряда не красим. */
|
||||
:root { --series-1: #6a45b8; --series-2: #c4620a; }
|
||||
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
|
||||
|
||||
/* Строка над плитками: период слева, выбор сервера (components/ServerSelect) справа; на узком — друг под другом */
|
||||
.mon-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin-bottom: 16px; }
|
||||
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.mon-range {
|
||||
min-height: 44px; padding: 0 18px;
|
||||
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline); border-radius: var(--shape-full);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
|
||||
|
||||
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.mon-tile {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant); border-radius: var(--shape-large);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.mon-tile-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.mon-tile-value { font-size: var(--title-large-size); line-height: var(--title-large-line); font-weight: var(--weight-medium); color: var(--on-surface); }
|
||||
.mon-tile-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
|
||||
.mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; }
|
||||
.mon-tile-dir { font-size: var(--body-large-size); line-height: var(--body-large-line); }
|
||||
.mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); }
|
||||
.mon-tile-meter { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
|
||||
|
||||
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
@media (min-width: 1000px) {
|
||||
.mon-charts { grid-template-columns: 1fr 1fr; }
|
||||
.mon-charts .mon-chart:first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
.mon-chart {
|
||||
margin: 0; min-width: 0;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant); border-radius: var(--shape-extra-large);
|
||||
padding: 16px;
|
||||
transition: opacity var(--duration-short3) var(--ease-standard);
|
||||
}
|
||||
.mon-chart--dim { opacity: .6; }
|
||||
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
|
||||
.mon-chart-title { font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
|
||||
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: var(--shape-extra-small); flex: none; }
|
||||
.mon-chart-toggle {
|
||||
margin-left: auto; min-height: 44px; padding: 0 12px;
|
||||
font: inherit; font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-primary-container);
|
||||
background: none; border: 0; border-radius: var(--shape-full); cursor: pointer;
|
||||
}
|
||||
|
||||
.mon-plot { position: relative; }
|
||||
.mon-plot svg { display: block; max-width: 100%; }
|
||||
.mon-plot svg:focus-visible { border-radius: var(--shape-small); }
|
||||
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
|
||||
.mon-tick { fill: var(--neutral-text); font-size: var(--body-small-size); font-family: var(--font); }
|
||||
.mon-cross { stroke: var(--outline); stroke-width: 1; }
|
||||
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
|
||||
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
|
||||
.mon-tip {
|
||||
position: absolute; pointer-events: none; z-index: 2;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-variant); border-radius: var(--shape-medium);
|
||||
padding: 8px 10px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
|
||||
}
|
||||
.mon-tip-time { color: var(--neutral-text); }
|
||||
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
|
||||
.mon-tip-row b { font-weight: var(--weight-medium); font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-surface); }
|
||||
|
||||
.mon-table-wrap { overflow-x: auto; }
|
||||
.mon-table { width: 100%; border-collapse: collapse; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
|
||||
.mon-table th { text-align: left; font-weight: var(--weight-medium); color: var(--on-surface-variant); padding: 8px 10px; white-space: nowrap; }
|
||||
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
.mon-notice { margin-bottom: 12px; }
|
||||
|
||||
/* Полоски процессора и памяти в плитках */
|
||||
.mon-bar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); margin: 2px 0; }
|
||||
.mon-bar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--series-1); transition: width var(--duration-medium2) var(--ease-standard); }
|
||||
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
|
||||
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
|
||||
|
||||
|
||||
|
||||
|
||||
/* Разбивка «По серверам» в режиме «Все серверы» */
|
||||
.mon-per-server { margin-bottom: 16px; }
|
||||
|
||||
/* Кнопка рядом с подписью — по центру строки */
|
||||
.mon-actions { align-items: center; }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
|
||||
|
||||
/* Открытый API-ключ (показывается один раз при создании) */
|
||||
.apikey-value {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--shape-medium);
|
||||
padding: 12px 14px;
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.apikey-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.apikey-empty {
|
||||
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.apikey-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--shape-extra-large);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.apikey-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.apikey-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.apikey-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.apikey-card-label {
|
||||
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.apikey-card-prefix {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||
color: var(--neutral-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.apikey-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: var(--body-small-size); line-height: var(--body-small-line);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
|
||||
/* compact и medium (< 840px): карточки вместо таблицы, на планшете — сеткой */
|
||||
@media (max-width: 839px) {
|
||||
.apikey-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
/* счётчик и «пусто» — на всю строку сетки */
|
||||
:is(.apikey-count, .apikey-empty) { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* Настройки ключа: какие серверы он видит */
|
||||
.apikey-tr-tools td { padding-top: 0; }
|
||||
.apikey-tools {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--shape-large);
|
||||
padding: 12px 16px 14px;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.apikey-tools-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
|
||||
.apikey-tools-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.apikey-card-servers { font-size: 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; }
|
||||
@@ -0,0 +1,207 @@
|
||||
// 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 { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../../components/ServerPicker';
|
||||
import './apikeys.css';
|
||||
|
||||
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
||||
// один раз при создании (в БД хранится только хэш). Раздел вкладки «Настройки».
|
||||
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
|
||||
// управляет мастер-ключами. По умолчанию новый ключ видит выбранный в панели сервер, набор
|
||||
// правится в настройках ключа.
|
||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
|
||||
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в панели
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/ui/apikeys', token);
|
||||
setKeys(data.keys ?? []);
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки ключей');
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => {
|
||||
try {
|
||||
await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next });
|
||||
showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
|
||||
}
|
||||
}, [token, nodes, load, showMsg]);
|
||||
|
||||
const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', ');
|
||||
|
||||
const toolbox = (k: ApiKey) => (
|
||||
<section className="apikey-tools">
|
||||
<h4 className="apikey-tools-title">Серверы ключа</h4>
|
||||
<p className="apikey-tools-hint">
|
||||
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
|
||||
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
|
||||
</p>
|
||||
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
|
||||
</section>
|
||||
);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const def = getServerId() ?? 0;
|
||||
const servers = picked ?? [def];
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setPicked(null);
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
await load();
|
||||
}, [label, picked, token, load, showMsg]);
|
||||
|
||||
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
||||
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
||||
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
|
||||
showMsg('Ключ удалён: ' + lbl);
|
||||
await load();
|
||||
}, [token, load, showMsg]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Метка ключа (напр. ci-bot)"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createKey()}
|
||||
/>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={createKey}>
|
||||
<IcoPlus /> Создать ключ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="apikey-new-servers">
|
||||
<span className="apikey-tools-hint">Серверы нового ключа:</span>
|
||||
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
|
||||
onChange={next => setPicked(next)} />
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Серверы</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<Fragment key={k.id}>
|
||||
<tr>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{serversText(k)}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="Серверы ключа"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
|
||||
<div className="apikey-cards-list">
|
||||
<p className="apikey-count">{keys.length} ключей</p>
|
||||
{keys.length === 0 ? (
|
||||
<p className="apikey-empty">Нет ключей</p>
|
||||
) : keys.map((k, i) => (
|
||||
<div className="apikey-card" key={k.id}>
|
||||
<div className="apikey-card-header">
|
||||
<div className="apikey-card-title">
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<div className="apikey-card-btns">
|
||||
<button
|
||||
className="btn btn--tonal btn--sq"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
|
||||
{tools === k.id && toolbox(k)}
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{created && (
|
||||
<div className="qr-backdrop" onClick={() => setCreated(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">Ключ «{created.label}» создан</p>
|
||||
<p className="qr-hint">
|
||||
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
|
||||
</p>
|
||||
<code className="apikey-value">{created.key}</code>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
await copyText(created.key);
|
||||
showMsg('Ключ скопирован');
|
||||
}}><IcoCopy /> Скопировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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. */
|
||||
|
||||
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.au-run { margin-left: auto; }
|
||||
.au-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.au-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;
|
||||
}
|
||||
.au-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
|
||||
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
|
||||
.au-table td, .au-table th { white-space: nowrap; }
|
||||
|
||||
/* мобильный: таблица скрыта (.table-card), то же — карточками */
|
||||
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
|
||||
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
|
||||
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.au-card .au-pub { max-width: 100%; }
|
||||
@media (max-width: 839px) { .au-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } }
|
||||
|
||||
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||||
.au-run { margin-left: 0; }
|
||||
|
||||
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
|
||||
.au-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
|
||||
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.au-setting-name { font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
|
||||
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.au-max { width: 88px !important; }
|
||||
.au-select { width: auto !important; }
|
||||
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
|
||||
.au-log .au-pub { max-width: 240px; }
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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, timeAgo, type NodeInfo, type PageProps } from '../../../lib/shared';
|
||||
import { IcoRefresh, IcoTrash } from '../../../components/icons';
|
||||
import { serverName } from '../../../components/ServerPicker';
|
||||
import './audit.css';
|
||||
|
||||
// Раздел «Аудит» вкладки «Настройки»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
|
||||
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
|
||||
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
|
||||
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
|
||||
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
|
||||
interface AuditEntry {
|
||||
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
|
||||
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
|
||||
}
|
||||
interface Settings { clean: boolean; every_h: number; max: number }
|
||||
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
|
||||
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
|
||||
|
||||
const PERIODS = [1, 3, 6, 12, 24];
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
export default function AuditPage({ token, showMsg }: PageProps) {
|
||||
const [audit, setAudit] = useState<AuditData | null>(null);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [maxEdit, setMaxEdit] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
} catch { showMsg('Ошибка загрузки аудита'); }
|
||||
}, [token, showMsg]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
|
||||
setBusy(true);
|
||||
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
finally { setBusy(false); }
|
||||
}, [token, showMsg]);
|
||||
const run = () => call('POST', '', 'Аудит выполнен');
|
||||
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
|
||||
|
||||
if (!audit) return null;
|
||||
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
|
||||
|
||||
const { settings } = audit;
|
||||
const servers = audit.report?.servers ?? [];
|
||||
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
|
||||
const bad = servers.filter(e => e.status !== 'ok');
|
||||
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
|
||||
const blocked = servers.filter(e => e.blocked);
|
||||
const rows = servers.flatMap(e => [
|
||||
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
|
||||
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
|
||||
]);
|
||||
const state = (p: AuditPeer) => (
|
||||
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
|
||||
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
|
||||
</span>
|
||||
);
|
||||
const confirm = (p: AuditPeer) => (
|
||||
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="au-head">
|
||||
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
|
||||
<span className="chip-dot" />
|
||||
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
|
||||
</span>
|
||||
{audit.report && (
|
||||
<span className="au-hint">
|
||||
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
|
||||
</span>
|
||||
)}
|
||||
<div className="au-btns">
|
||||
{confirmed > 0 && (
|
||||
<button className="btn btn--tonal" disabled={busy}
|
||||
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
|
||||
<IcoTrash /> Снять подтверждённых ({confirmed})
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--tonal" disabled={busy} onClick={run}>
|
||||
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="au-box">
|
||||
<h4 className="au-box-title">Параметры аудита</h4>
|
||||
<label className="au-setting">
|
||||
<span className="au-setting-text">
|
||||
<span className="au-setting-name">Снимать мёртвых автоматически</span>
|
||||
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
|
||||
</label>
|
||||
<div className="au-setting">
|
||||
<span className="au-setting-text">
|
||||
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
|
||||
<span className="au-hint">Как часто core обходит серверы</span>
|
||||
</span>
|
||||
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
|
||||
onChange={e => save({ every_h: Number(e.target.value) })}>
|
||||
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="au-setting">
|
||||
<span className="au-setting-text">
|
||||
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
|
||||
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
|
||||
</span>
|
||||
<span className="au-setting-ctl">
|
||||
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
|
||||
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
|
||||
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
|
||||
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{bad.length > 0 && (
|
||||
<p className="au-notice">
|
||||
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{blocked.length > 0 && (
|
||||
<p className="au-notice">
|
||||
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
|
||||
</p>
|
||||
)}
|
||||
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
|
||||
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<div className="table-card au-table-card">
|
||||
<table>
|
||||
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(({ e, p, why }) => (
|
||||
<tr key={`${e.server_id}:${p.pub_key}`}>
|
||||
<td>{serverName(nodes, e.server_id)}</td>
|
||||
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
|
||||
<td>{why}</td>
|
||||
<td>{p.key ?? '—'}</td>
|
||||
<td>{state(p)}<br />{confirm(p)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="au-cards">
|
||||
{rows.map(({ e, p, why }) => (
|
||||
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
|
||||
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
|
||||
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
|
||||
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
|
||||
{confirm(p)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{audit.cleaned.length > 0 && (
|
||||
<section className="au-box">
|
||||
<h4 className="au-box-title">Снято</h4>
|
||||
<ul className="au-log">
|
||||
{audit.cleaned.slice(0, 20).map(c => (
|
||||
<li key={`${c.at}:${c.pub_key}`}>
|
||||
<span className="au-hint">{timeAgo(c.at)} назад</span>
|
||||
<span>{serverName(nodes, c.server_id)}</span>
|
||||
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
|
||||
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 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 { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { apiFetch, type PageProps, type ShapingStatus, type TorrentStatus } from '../../../lib/shared';
|
||||
import './server.css';
|
||||
|
||||
// Раздел «Сервер» вкладки «Настройки»: деление канала между устройствами (CAKE) и блокировка BitTorrent
|
||||
// для сервера из шапки (при его смене вкладка перемонтируется). Сами настройки живут в awg-ctrl этого
|
||||
// сервера — запрос идёт обычным прокси с X-Server-Id, так что ядро и ноды настраиваются одинаково.
|
||||
export default function ServerSettings({ token, showMsg }: PageProps) {
|
||||
const [st, setSt] = useState<ShapingStatus | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [auto, setAuto] = useState(true);
|
||||
const [down, setDown] = useState('');
|
||||
const [up, setUp] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [tst, setTst] = useState<TorrentStatus | null>(null);
|
||||
const [tEnabled, setTEnabled] = useState(false);
|
||||
const [tUnsupported, setTUnsupported] = useState(false);
|
||||
|
||||
const fill = (s: ShapingStatus) => {
|
||||
setSt(s);
|
||||
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
|
||||
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
|
||||
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
|
||||
};
|
||||
|
||||
const errText = (e: unknown, fallback: string) =>
|
||||
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
|
||||
|
||||
const fillTorrent = (s: TorrentStatus) => { setTst(s); setTEnabled(s.settings.enabled); };
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiFetch('GET', '/api/torrent', token)
|
||||
.then(s => { if (alive) fillTorrent(s as TorrentStatus); })
|
||||
.catch(e => {
|
||||
if (!alive) return;
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) setTUnsupported(true);
|
||||
else setError(errText(e, 'Не удалось загрузить настройки'));
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiFetch('GET', '/api/shaping', token)
|
||||
.then(s => { if (alive) fill(s as ShapingStatus); })
|
||||
.catch(e => {
|
||||
if (!alive) return;
|
||||
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
|
||||
else setError(errText(e, 'Не удалось загрузить настройки'));
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [token]);
|
||||
|
||||
const downN = Number(down), upN = Number(up);
|
||||
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
|
||||
|
||||
// Отправляем только изменённое: лишний PUT /api/shaping заново ставил бы tc на интерфейс.
|
||||
const shapingDirty = !!st && (enabled !== st.settings.enabled || auto !== st.settings.auto
|
||||
|| (Number(down) || 0) !== st.settings.downMbit || (Number(up) || 0) !== st.settings.upMbit);
|
||||
const torrentDirty = !!tst && tEnabled !== tst.settings.enabled;
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError('');
|
||||
const done: string[] = [];
|
||||
try {
|
||||
if (shapingDirty) {
|
||||
const s = await apiFetch('PUT', '/api/shaping', token, {
|
||||
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
|
||||
});
|
||||
fill(s as ShapingStatus);
|
||||
done.push(enabled ? 'деление канала включено' : 'деление канала выключено');
|
||||
}
|
||||
if (torrentDirty) {
|
||||
const s = await apiFetch('PUT', '/api/torrent', token, { enabled: tEnabled });
|
||||
fillTorrent(s as TorrentStatus);
|
||||
done.push(tEnabled ? 'торренты блокируются' : 'блокировка торрентов выключена');
|
||||
}
|
||||
if (done.length) showMsg(done.join(', ').replace(/^./, c => c.toUpperCase()));
|
||||
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="st-section ss-card">
|
||||
<h3 className="ss-section">Деление канала</h3>
|
||||
|
||||
{unsupported ? (
|
||||
<p className="st-hint">
|
||||
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="st-hint">
|
||||
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
|
||||
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
|
||||
</p>
|
||||
|
||||
<label className="ss-setting">
|
||||
<span className="ss-setting-text">
|
||||
<span className="ss-setting-name">Честное деление между устройствами</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
<div className="ss-grid">
|
||||
<label className="ss-field">
|
||||
<span className="ss-setting-name">Загрузка, Мбит/с</span>
|
||||
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
|
||||
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
|
||||
</label>
|
||||
<label className="ss-field">
|
||||
<span className="ss-setting-name">Отдача, Мбит/с</span>
|
||||
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
|
||||
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="st-hint">
|
||||
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
|
||||
и деление перестаёт работать.
|
||||
</p>
|
||||
|
||||
<label className="ss-setting">
|
||||
<span className="ss-setting-text">
|
||||
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
|
||||
<span className="st-hint">
|
||||
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
|
||||
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
|
||||
</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
{st?.settings.enabled && (
|
||||
<div className="ss-status">
|
||||
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
|
||||
</span>
|
||||
<span className="ss-mono">
|
||||
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
|
||||
</span>
|
||||
{st.heavy.length > 0 && (
|
||||
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
|
||||
<section className="st-section ss-card">
|
||||
<h3 className="ss-section">Торренты</h3>
|
||||
{tUnsupported ? (
|
||||
<p className="st-hint">
|
||||
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="ss-setting">
|
||||
<span className="ss-setting-text">
|
||||
<span className="ss-setting-name">Блокировать BitTorrent</span>
|
||||
<span className="st-hint">
|
||||
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
|
||||
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
|
||||
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
|
||||
</span>
|
||||
<span className="st-hint">
|
||||
{enabled
|
||||
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
|
||||
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
|
||||
</span>
|
||||
</span>
|
||||
<input type="checkbox" checked={tEnabled} disabled={!tst || busy} onChange={e => setTEnabled(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
{tst?.settings.enabled && (
|
||||
<div className="ss-status">
|
||||
<span className={`chip ${tst.active ? 'chip--online' : 'chip--offline'}`}>
|
||||
<span className="chip-dot" />{tst.active ? 'работает' : 'не применено'}
|
||||
</span>
|
||||
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
|
||||
{tst.active && !tst.tcp && (
|
||||
<span className="st-hint">
|
||||
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
|
||||
</span>
|
||||
)}
|
||||
{tst.error && <span className="st-hint">{tst.error}</span>}
|
||||
{tst.seen.length > 0 && (
|
||||
<>
|
||||
<span className="st-hint">Пытались качать за последние 10 минут:</span>
|
||||
<span className="ss-seen">
|
||||
{tst.seen.map(p => (
|
||||
<span key={p.ip} className="chip chip--error">
|
||||
<span className="chip-dot" />{p.name ? `${p.name} · ${p.ip}` : p.ip}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
{!(unsupported && tUnsupported) && (
|
||||
<div className="ss-actions">
|
||||
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,12 +2,14 @@
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
|
||||
type User, type PageProps, type AwgGen,
|
||||
} from '../../lib/shared';
|
||||
import { qrDataUrl } from '../../lib/qr';
|
||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||
import PriorityChip from '../../components/PriorityChip';
|
||||
import TorrentChip from '../../components/TorrentChip';
|
||||
import './users.css';
|
||||
|
||||
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
||||
@@ -19,6 +21,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
|
||||
// поколении, уже не подключатся — их надо перевыпустить.
|
||||
const [serverGen, setServerGen] = useState<AwgGen>('2');
|
||||
const [upgrading, setUpgrading] = useState(false);
|
||||
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const stale = users.filter(u => u.key_gen !== serverGen);
|
||||
|
||||
@@ -27,7 +30,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
const data = await apiFetch('GET', '/api/users/stats', tok);
|
||||
setUsers(prev => prev.map(u => {
|
||||
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u;
|
||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority, torrent: s.torrent } : u;
|
||||
}));
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
@@ -50,7 +53,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
|
||||
const reissueKeys = useCallback(async () => {
|
||||
if (!confirm(
|
||||
`Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` +
|
||||
`Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
|
||||
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
|
||||
)) return;
|
||||
try {
|
||||
@@ -62,6 +65,28 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
}
|
||||
}, [stale.length, serverGen, token, loadUsers, showMsg]);
|
||||
|
||||
// Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает
|
||||
// 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро
|
||||
// их не примет) и сразу перевыпускает ключи — старые собраны на
|
||||
// 2.0-параметрах и работать не будут.
|
||||
const upgradeGen = useCallback(async () => {
|
||||
if (!confirm(
|
||||
'Перевести сервер на AmneziaWG 3.1?\n\n' +
|
||||
'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' +
|
||||
'клиентам придётся импортировать ключ заново.',
|
||||
)) return;
|
||||
setUpgrading(true);
|
||||
try {
|
||||
const r = await apiFetch('POST', '/awg/upgrade', token);
|
||||
showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`);
|
||||
await loadUsers(token);
|
||||
} catch (e: any) {
|
||||
showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1');
|
||||
} finally {
|
||||
setUpgrading(false);
|
||||
}
|
||||
}, [token, loadUsers, showMsg]);
|
||||
|
||||
const createUser = useCallback(async () => {
|
||||
if (!newName) return;
|
||||
const u = await apiFetch('POST', '/api/users', token, { name: newName });
|
||||
@@ -84,12 +109,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
const vpnKey = u.vpn_key ?? '';
|
||||
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
|
||||
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
|
||||
const dataUrl = await QRCode.toDataURL(vpnKey, {
|
||||
width: 560,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'L',
|
||||
});
|
||||
setQrModal({ name, dataUrl, vpnKey });
|
||||
setQrModal({ name, dataUrl: qrDataUrl(vpnKey, 'L'), vpnKey });
|
||||
} catch {
|
||||
showMsg('Не удалось сгенерировать QR-код');
|
||||
}
|
||||
@@ -120,6 +140,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
|
||||
<IcoRefresh /> Обновить
|
||||
</button>
|
||||
{serverGen !== '3.1' && (
|
||||
<button className="btn btn--danger" onClick={upgradeGen} disabled={upgrading}>
|
||||
<IcoRefresh /> {upgrading ? 'Переход…' : 'Перейти на AWG 3.1'}
|
||||
</button>
|
||||
)}
|
||||
{stale.length > 0 && (
|
||||
<button className="btn btn--danger" onClick={reissueKeys}>
|
||||
<IcoRefresh /> Перевыпустить ключи ({stale.length})
|
||||
@@ -134,6 +159,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Статус</th>
|
||||
<th>Приоритет</th>
|
||||
<th>Имя</th>
|
||||
<th>IP</th>
|
||||
<th>↓ rx</th>
|
||||
@@ -143,7 +169,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
|
||||
<tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
|
||||
) : users.map((u, i) => (
|
||||
<tr key={u.name}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
@@ -157,16 +183,17 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="user-chips"><PriorityChip priority={u.priority} torrent={u.torrent} /><TorrentChip torrent={u.torrent} /></span></td>
|
||||
<td>
|
||||
{u.name}
|
||||
{u.key_gen !== serverGen && (
|
||||
<span
|
||||
className="tip-wrap user-gen"
|
||||
data-tip={`Ключ выдан на AWG ${u.key_gen}, сервер работает на ${serverGen}`}
|
||||
data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
|
||||
>
|
||||
<span className="chip chip--error">
|
||||
<span className="chip-dot" />
|
||||
AWG {u.key_gen}
|
||||
{genLabel(u.key_gen)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -207,7 +234,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
{u.key_gen !== serverGen && (
|
||||
<span className="chip chip--error">
|
||||
<span className="chip-dot" />
|
||||
AWG {u.key_gen}
|
||||
{genLabel(u.key_gen)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -223,6 +250,8 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<div className="user-card-ip">
|
||||
<IcoGlobe />
|
||||
<span className="user-card-ip-text">{u.ip}</span>
|
||||
<PriorityChip priority={u.priority} torrent={u.torrent} />
|
||||
<TorrentChip torrent={u.torrent} />
|
||||
</div>
|
||||
<div className="user-card-metrics">
|
||||
<div className="metric-block">
|
||||
@@ -263,6 +292,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
/>
|
||||
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--text" onClick={() => setQrModal(null)}>Закрыть</button>
|
||||
<button className="btn btn--tonal" onClick={async () => {
|
||||
try {
|
||||
const conf = await vpnKeyToConf(qrModal.vpnKey);
|
||||
@@ -271,7 +301,6 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
showMsg('Конфиг скачан');
|
||||
} catch { showMsg('Не удалось декодировать ключ'); }
|
||||
}}>Скачать .conf</button>
|
||||
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "users",
|
||||
"label": "Пользователи",
|
||||
"icon": "users",
|
||||
"group": "main",
|
||||
"order": 1
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ LICENSE file in the root directory of this source tree.
|
||||
|
||||
.user-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.users-count { font-size: 12px; color: var(--neutral-text); }
|
||||
.users-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.empty-cards {
|
||||
font-size: 14px;
|
||||
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
@@ -18,7 +18,7 @@ LICENSE file in the root directory of this source tree.
|
||||
.user-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
border-radius: var(--shape-extra-large);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -36,10 +36,10 @@ LICENSE file in the root directory of this source tree.
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.user-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.user-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -47,24 +47,28 @@ LICENSE file in the root directory of this source tree.
|
||||
}
|
||||
.user-card-ip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.user-card-ip-text { font-family: var(--mono); font-size: 13px; }
|
||||
.user-card-ip-text { font-family: var(--mono); font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
|
||||
/* чипы приоритета и торрентов — рядом с IP, как в карточке устройства мастер-ключа (на телефоне
|
||||
.tip-wrap по умолчанию во всю ширину и уводил чип на отдельную строку) */
|
||||
.user-card-ip .tip-wrap { width: auto; }
|
||||
|
||||
.user-card-metrics { display: flex; gap: 8px; }
|
||||
.metric-block {
|
||||
flex: 1;
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--r-table);
|
||||
border-radius: var(--shape-large);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.metric-label { font-size: 12px; color: var(--neutral-text); }
|
||||
.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); }
|
||||
.metric-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
|
||||
.metric-value { font-family: var(--mono); font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
|
||||
|
||||
.user-card-actions { display: flex; gap: 8px; }
|
||||
.user-card-actions .btn--outline { flex: 1; }
|
||||
@@ -72,9 +76,17 @@ LICENSE file in the root directory of this source tree.
|
||||
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
|
||||
.user-gen { margin-left: 8px; vertical-align: middle; }
|
||||
|
||||
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
||||
@media (max-width: 640px) {
|
||||
.user-cards-list { display: flex; }
|
||||
/* compact и medium (< 840px): таблица скрывается (.table-card в App.css), показываются карточки —
|
||||
в одну колонку на телефоне, сеткой на планшете. */
|
||||
@media (max-width: 839px) {
|
||||
.user-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
/* счётчик и «пусто» — на всю строку сетки */
|
||||
:is(.users-count, .empty-cards) { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 599px) {
|
||||
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
|
||||
.user-gen { width: auto; }
|
||||
}
|
||||
|
||||
/* Чипы приоритета и торрентов в одной ячейке */
|
||||
.user-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1165
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -4,14 +4,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const api = `http://localhost:${process.env.MOCK_PORT || 8080}`;
|
||||
|
||||
export default defineConfig({
|
||||
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
|
||||
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
|
||||
// (main.tsx).
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
'/login': 'http://localhost:8080',
|
||||
'/logout': 'http://localhost:8080',
|
||||
'/api': api,
|
||||
'/ui': api,
|
||||
'/health': api,
|
||||
'/login': api,
|
||||
'/logout': api,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.1.4+1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+325
-18
@@ -1,7 +1,7 @@
|
||||
// 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 { spawn } from "child_process";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { readFileSync, writeFileSync, existsSync,
|
||||
mkdirSync, openSync, closeSync,
|
||||
unlinkSync } from "fs";
|
||||
@@ -9,25 +9,38 @@ import * as readline from "readline";
|
||||
import * as crypto from "crypto";
|
||||
import * as http from "http";
|
||||
import path from "path";
|
||||
import { monitorCmd } from "./monitor";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../..");
|
||||
const LOGS = path.join(ROOT, "logs");
|
||||
|
||||
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
|
||||
|
||||
const envFile = path.join(__dirname, "..", "cli.env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split("\n")) {
|
||||
// Уже заданное окружение не перетираем (??=), поэтому порядок = приоритет:
|
||||
// реальное окружение → cli.env (машинные настройки, пишет install.sh) →
|
||||
// .env в корне проекта (имя панели и версия, едет в архиве релиза).
|
||||
function loadEnvFile(file: string) {
|
||||
if (!existsSync(file)) return;
|
||||
for (const line of readFileSync(file, "utf8").split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith("#")) continue;
|
||||
const idx = t.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const k = t.slice(0, idx).trim();
|
||||
const v = t.slice(idx + 1).trim();
|
||||
process.env[k] ??= v;
|
||||
process.env[t.slice(0, idx).trim()] ??= t.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const envFile = path.join(__dirname, "..", "cli.env"); // машинные настройки
|
||||
loadEnvFile(envFile);
|
||||
loadEnvFile(path.join(ROOT, ".env")); // имя панели и версия
|
||||
|
||||
// Имя панели, канал и версия — из .env (или из cli.env, если оператор задал
|
||||
// своё имя при установке). В коде только запасные значения на случай, когда
|
||||
// .env не доехал.
|
||||
const BRAND = process.env.BRAND || "Forgetting";
|
||||
const CHANNEL = process.env.CHANNEL || "Beta";
|
||||
const VERSION = process.env.VERSION || "";
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
||||
|
||||
const C = {
|
||||
@@ -71,19 +84,78 @@ const SERVICES = {
|
||||
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
|
||||
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
|
||||
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
|
||||
// Панель показывает их в вордмарке (GET /ui/brand).
|
||||
BRAND: BRAND,
|
||||
CHANNEL: CHANNEL,
|
||||
VERSION: VERSION,
|
||||
UI_USER: process.env.UI_USER ?? "admin",
|
||||
UI_PASS: process.env.UI_PASS ?? "",
|
||||
JWT_SECRET: process.env.JWT_SECRET ?? "",
|
||||
// ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку.
|
||||
// ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) —
|
||||
// переживает переустановку.
|
||||
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
|
||||
// Подписка sen:// (мастер-ключи SenAWG): порт, режим TLS, ключ подписи и
|
||||
// сертификат. Без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
|
||||
SUB_PORT: process.env.SUB_PORT ?? "",
|
||||
SUB_TLS: process.env.SUB_TLS ?? "off",
|
||||
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
|
||||
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
|
||||
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
|
||||
// Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального
|
||||
// сервера (у панели без своего VPN — тот, по которому к ней зашли).
|
||||
SUB_HOST: process.env.SUB_HOST ?? "",
|
||||
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
|
||||
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
|
||||
// core без собственного VPN: awg-ctrl рядом не нужен.
|
||||
NODE_PORT: process.env.NODE_PORT ?? "",
|
||||
// Часовой аудит «мёртвых» пиров на всех серверах — только у core (у ноды со своей панелью выключен).
|
||||
AUDIT: process.env.CORE_HOST ? "off" : (process.env.AUDIT ?? "on"),
|
||||
// Пер-пировые серии в /metrics: только по явному согласию оператора (cli.env).
|
||||
METRICS_PEERS: process.env.METRICS_PEERS ?? "off",
|
||||
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
|
||||
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
|
||||
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
|
||||
},
|
||||
pidFile: "/tmp/awg-ui.pid",
|
||||
logFile: path.join(LOGS, "awg-ui.log"),
|
||||
},
|
||||
// Агент ноды: сам звонит в core (CORE_HOST/PORT/PIN из join-строки) и исполняет запросы
|
||||
// на локальном awg-ctrl. Запускается вместо awg-ui — см. ROLE.
|
||||
agent: {
|
||||
label: "awg-agent",
|
||||
cwd: path.join(ROOT, "awg-agent"),
|
||||
entry: "index.ts",
|
||||
env: {
|
||||
CORE_HOST: process.env.CORE_HOST ?? "",
|
||||
CORE_PORT: process.env.CORE_PORT ?? "",
|
||||
CORE_PIN: process.env.CORE_PIN ?? "",
|
||||
NODE_ID: process.env.NODE_ID ?? "",
|
||||
NODE_KEY_FILE: process.env.NODE_KEY_FILE ?? "/etc/amnezia/amneziawg/node.key",
|
||||
JOIN_SECRET: process.env.JOIN_SECRET ?? "",
|
||||
CLI_ENV_FILE: envFile,
|
||||
AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005",
|
||||
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
|
||||
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
|
||||
},
|
||||
pidFile: "/tmp/awg-agent.pid",
|
||||
logFile: path.join(LOGS, "awg-agent.log"),
|
||||
},
|
||||
} as const;
|
||||
|
||||
type SvcName = keyof typeof SERVICES;
|
||||
const ALL: SvcName[] = ["awgctrl", "ui"];
|
||||
|
||||
// Роль установки. node — VPN-сервер без панели: awg-ctrl + агент, который сам
|
||||
// подключается к core (есть CORE_HOST, его пишет `join`). core с LOCAL_NODE=off —
|
||||
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
|
||||
const IS_NODE = !!process.env.CORE_HOST;
|
||||
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
|
||||
// Нода со своей панелью: работающий сервер «панель + VPN» подключили к главной панели, не
|
||||
// выключая его собственную (join без --no-panel). Обе панели ходят в один awg-ctrl — он и
|
||||
// есть источник правды, поэтому друг другу они не мешают. У ноды, поставленной install.sh,
|
||||
// панели нет (UI_PASS не задан).
|
||||
const NODE_PANEL = IS_NODE && !!process.env.UI_PASS && (process.env.NODE_PANEL ?? "on").toLowerCase() !== "off";
|
||||
const ALL: SvcName[] = IS_NODE ? (NODE_PANEL ? ["awgctrl", "ui", "agent"] : ["awgctrl", "agent"])
|
||||
: NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
|
||||
|
||||
function readPid(name: SvcName): number | null {
|
||||
const { pidFile } = SERVICES[name];
|
||||
@@ -196,7 +268,27 @@ async function restart(name: SvcName) {
|
||||
await start(name);
|
||||
}
|
||||
|
||||
function status() {
|
||||
// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
|
||||
// молча даёт null — health-строка в status() тогда просто не печатается.
|
||||
interface HealthBody {
|
||||
gen?: string;
|
||||
awg?: { module?: string; tools?: string };
|
||||
}
|
||||
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
|
||||
return new Promise(resolve => {
|
||||
const req = http.get(url, res => {
|
||||
let body = "";
|
||||
res.on("data", (c: Buffer) => { body += c; });
|
||||
res.on("end", () => {
|
||||
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
|
||||
});
|
||||
}
|
||||
|
||||
async function status() {
|
||||
console.log("");
|
||||
for (const name of ALL) {
|
||||
const running = isRunning(name);
|
||||
@@ -205,6 +297,13 @@ function status() {
|
||||
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
|
||||
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
|
||||
}
|
||||
if (isRunning("awgctrl")) {
|
||||
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
|
||||
if (h?.gen) {
|
||||
const gen = h.gen === "3.1" ? "3.1" : "2.0";
|
||||
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
@@ -219,7 +318,7 @@ function probe(url: string, timeoutMs = 1000): Promise<boolean> {
|
||||
|
||||
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
|
||||
// ensureInterfaceUp() бросил исключение) — сразу выходим.
|
||||
async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
|
||||
async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
|
||||
const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (!isRunning(name)) return false;
|
||||
@@ -230,12 +329,16 @@ async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function startAll() {
|
||||
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
|
||||
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
|
||||
|
||||
await start("awgctrl");
|
||||
const rest = ALL.filter(n => n !== "awgctrl");
|
||||
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
|
||||
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
||||
console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
||||
return;
|
||||
}
|
||||
await start("ui");
|
||||
for (const n of rest) await start(n);
|
||||
}
|
||||
|
||||
async function stopAll() { for (const n of ALL) await stop(n); }
|
||||
@@ -248,6 +351,206 @@ function updateEnvVar(content: string, key: string, value: string): string {
|
||||
: content.trimEnd() + `\n${key}=${value}\n`;
|
||||
}
|
||||
|
||||
function removeEnvVar(content: string, key: string): string {
|
||||
return content.split("\n").filter(l => !l.startsWith(`${key}=`)).join("\n");
|
||||
}
|
||||
|
||||
// Роль читается из окружения при старте процесса, поэтому после смены cli.env
|
||||
// перезапуск делаем свежим процессом CLI, а не в этом же.
|
||||
function restartFresh(): void {
|
||||
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
|
||||
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
|
||||
// работать, если она здесь есть, — с --no-panel она останавливается.
|
||||
async function joinCore(link?: string, flag?: string) {
|
||||
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
|
||||
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
|
||||
process.exit(1);
|
||||
}
|
||||
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
|
||||
try { j = JSON.parse(Buffer.from(link.slice("awgjoin://".length), "base64url").toString("utf8")); }
|
||||
catch { console.error(" Ссылка повреждена"); process.exit(1); }
|
||||
const ok = j.v === 1 && typeof j.h === "string" && /^[A-Za-z0-9.:_-]{1,253}$/.test(j.h)
|
||||
&& Number.isInteger(j.p) && j.p! > 0 && j.p! < 65536
|
||||
&& typeof j.pin === "string" && /^[A-Za-z0-9_-]{43}$/.test(j.pin)
|
||||
&& Number.isInteger(j.n) && j.n! > 0 && typeof j.s === "string" && /^[A-Za-z0-9_-]{22}$/.test(j.s);
|
||||
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
|
||||
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
|
||||
|
||||
const keepPanel = flag !== "--no-panel" && !!process.env.UI_PASS;
|
||||
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||
const set: Record<string, string> = {
|
||||
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
|
||||
NODE_PANEL: keepPanel ? "on" : "off",
|
||||
};
|
||||
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
|
||||
writeFileSync(envFile, content, { mode: 0o600 });
|
||||
|
||||
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
|
||||
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
|
||||
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
|
||||
if (!keepPanel) await stop("ui");
|
||||
if (dropPeers) await dropArea(localPanelId()!);
|
||||
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
|
||||
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
|
||||
restartFresh();
|
||||
}
|
||||
|
||||
// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────────
|
||||
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели.
|
||||
// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области
|
||||
// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели
|
||||
// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для
|
||||
// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется.
|
||||
const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id");
|
||||
const AREA_RE = /^[a-z0-9]{8,16}$/;
|
||||
const LEGACY_OWNER = /^m\d+\/d\d+$/;
|
||||
|
||||
function localPanelId(): string | null {
|
||||
try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с).
|
||||
function mintInternalToken(): string {
|
||||
const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE));
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
|
||||
const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url");
|
||||
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`;
|
||||
}
|
||||
|
||||
function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = http.request({
|
||||
host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json",
|
||||
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
}, res => {
|
||||
let raw = "";
|
||||
res.on("data", c => { raw += c; });
|
||||
res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } });
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("timeout")));
|
||||
req.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean }
|
||||
|
||||
async function listPeers(): Promise<CtrlPeer[]> {
|
||||
const r = await ctrlCall("GET", "/api/peers");
|
||||
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
|
||||
return r.data.peers as CtrlPeer[];
|
||||
}
|
||||
|
||||
// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш).
|
||||
function areaOf(owner: string): string | null {
|
||||
const head = owner.split("/")[0];
|
||||
if (owner.includes("/") && AREA_RE.test(head)) return head;
|
||||
return LEGACY_OWNER.test(owner) ? "legacy" : null;
|
||||
}
|
||||
|
||||
const peersWord = (n: number) =>
|
||||
n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров";
|
||||
|
||||
async function askDropPanelPeers(): Promise<boolean> {
|
||||
const own = localPanelId();
|
||||
if (!own) return false;
|
||||
let n: number;
|
||||
try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; }
|
||||
catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; }
|
||||
if (n === 0) return false;
|
||||
|
||||
const preset = (process.env.NODE_PEERS ?? "").toLowerCase();
|
||||
if (preset === "keep" || preset === "drop") return preset === "drop";
|
||||
console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`);
|
||||
console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`);
|
||||
console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`);
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const a = await new Promise<string>(r => rl.question(" Выбор [1]: ", r));
|
||||
rl.close();
|
||||
return a.trim() === "2";
|
||||
}
|
||||
|
||||
// Снять всю область: для панели — пустым списком через sync, для legacy — по одному.
|
||||
async function dropArea(area: string): Promise<void> {
|
||||
try {
|
||||
if (area === "legacy") {
|
||||
const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy");
|
||||
for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`);
|
||||
console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`);
|
||||
return;
|
||||
}
|
||||
const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] });
|
||||
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
|
||||
console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`);
|
||||
} catch (e) {
|
||||
console.error(` Не удалось снять пиров: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function peersCmd(sub?: string, arg?: string) {
|
||||
const own = localPanelId();
|
||||
let peers: CtrlPeer[];
|
||||
try { peers = await listPeers(); }
|
||||
catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); }
|
||||
const areas = new Map<string, CtrlPeer[]>();
|
||||
for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); }
|
||||
|
||||
if (sub === undefined) {
|
||||
if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; }
|
||||
console.log("\n Пиры подписки (sen://) на этом сервере:\n");
|
||||
for (const [a, list] of areas) {
|
||||
const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : "";
|
||||
console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` +
|
||||
` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`);
|
||||
}
|
||||
console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`);
|
||||
return;
|
||||
}
|
||||
if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) {
|
||||
console.error(" Usage: awg-ctrl peers [drop <область|legacy>]");
|
||||
process.exit(1);
|
||||
}
|
||||
const n = areas.get(arg)?.length ?? 0;
|
||||
if (!n) { console.log(` В области ${arg} пиров нет`); return; }
|
||||
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
|
||||
if (arg === own && isRunning("ui")) {
|
||||
console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`);
|
||||
console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`);
|
||||
if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return;
|
||||
await dropArea(arg);
|
||||
}
|
||||
|
||||
async function unjoinCore() {
|
||||
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
|
||||
await stop("agent");
|
||||
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"]) content = removeEnvVar(content, k);
|
||||
writeFileSync(envFile, content, { mode: 0o600 });
|
||||
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
|
||||
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
|
||||
if (!process.env.UI_PASS)
|
||||
console.log(` ${dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials")}`);
|
||||
}
|
||||
|
||||
async function confirmYes(q: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const a = await new Promise<string>(r => rl.question(`${q} [y/N] `, r));
|
||||
rl.close();
|
||||
return /^y/i.test(a.trim());
|
||||
}
|
||||
|
||||
async function setCredentials(field?: "user" | "pass") {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
|
||||
@@ -287,7 +590,7 @@ async function interactiveMenu() {
|
||||
|
||||
const printMenu = () => {
|
||||
console.clear();
|
||||
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4.1 ──${C.reset}`)}\n`);
|
||||
console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
|
||||
|
||||
for (const name of ALL) {
|
||||
const running = isRunning(name);
|
||||
@@ -321,7 +624,7 @@ async function interactiveMenu() {
|
||||
case "1": await startAll(); break;
|
||||
case "2": await stopAll(); break;
|
||||
case "3": await restartAll(); break;
|
||||
case "4": status(); break;
|
||||
case "4": await status(); break;
|
||||
case "5": await setCredentials("user"); break;
|
||||
case "6": await setCredentials("pass"); break;
|
||||
case "0": rl.close(); process.exit(0);
|
||||
@@ -342,20 +645,24 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]>";
|
||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>";
|
||||
|
||||
const [,,, sub] = process.argv;
|
||||
const [,,, sub, arg] = process.argv;
|
||||
|
||||
switch (cmd) {
|
||||
case "start": await startAll(); break;
|
||||
case "stop": await stopAll(); break;
|
||||
case "restart": await restartAll(); break;
|
||||
case "status": status(); break;
|
||||
case "status": await status(); break;
|
||||
case "credentials":
|
||||
if (sub === "user") await setCredentials("user");
|
||||
else if (sub === "pass") await setCredentials("pass");
|
||||
else await setCredentials();
|
||||
break;
|
||||
case "join": await joinCore(sub, arg); break;
|
||||
case "unjoin": await unjoinCore(); break;
|
||||
case "peers": await peersCmd(sub, arg); break;
|
||||
case "monitor": await monitorCmd(sub); break;
|
||||
default: console.log(USAGE); process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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.
|
||||
//
|
||||
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор».
|
||||
// Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не ставит, она только просит
|
||||
// (через awg-ctrl) или показывает эту команду. Всё слушает 127.0.0.1: наружу Prometheus не торчит,
|
||||
// а панель читает его по HTTP на той же машине.
|
||||
import { spawnSync } from "child_process";
|
||||
import { readFileSync, writeFileSync, existsSync, copyFileSync, unlinkSync } from "fs";
|
||||
import * as crypto from "crypto";
|
||||
import * as http from "http";
|
||||
|
||||
const AWG_DIR = "/etc/amnezia/amneziawg";
|
||||
const PROM_DIR = "/etc/prometheus";
|
||||
const CONF_FILE = `${PROM_DIR}/prometheus.yml`;
|
||||
const TOKEN_FILE = `${PROM_DIR}/forgetting.token`;
|
||||
// Хэш локального токена для awg-ui: читает его при проверке /metrics (сам токен знает только Prometheus).
|
||||
export const MONITOR_HASH_FILE = `${AWG_DIR}/monitor_token.sha256`;
|
||||
const DEFAULTS_FILE = "/etc/default/prometheus";
|
||||
const MARKER = "# managed by forgetting";
|
||||
const LISTEN = "127.0.0.1:9090";
|
||||
const RETENTION = "31d"; // с запасом на вид «30 дней» во вкладке «Монитор»
|
||||
|
||||
const log = (s: string) => console.log(` ${s}`);
|
||||
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
|
||||
|
||||
function sh(cmd: string, args: string[], opts: { quiet?: boolean } = {}) {
|
||||
return spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: opts.quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
|
||||
env: { ...process.env, DEBIAN_FRONTEND: "noninteractive" },
|
||||
});
|
||||
}
|
||||
|
||||
function osFamily(): string {
|
||||
try {
|
||||
const t = readFileSync("/etc/os-release", "utf8");
|
||||
const id = t.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
|
||||
const like = t.match(/^ID_LIKE=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
|
||||
return `${id} ${like}`;
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
function ready(timeoutMs = 1500): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const [host, port] = LISTEN.split(":");
|
||||
const req = http.get({ host, port: Number(port), path: "/-/ready", timeout: timeoutMs }, res => {
|
||||
res.resume(); resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(); resolve(false); });
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
const installed = () => sh("dpkg", ["-s", "prometheus"], { quiet: true }).status === 0;
|
||||
const active = () => sh("systemctl", ["is-active", "--quiet", "prometheus"], { quiet: true }).status === 0;
|
||||
const managed = () => existsSync(CONF_FILE) && readFileSync(CONF_FILE, "utf8").includes(MARKER);
|
||||
|
||||
// Новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
|
||||
function authBlock(): string {
|
||||
const v = sh("prometheus", ["--version"], { quiet: true });
|
||||
const m = `${v.stdout ?? ""}${v.stderr ?? ""}`.match(/version (\d+)\.(\d+)/);
|
||||
const [maj, min] = m ? [Number(m[1]), Number(m[2])] : [2, 0];
|
||||
return maj > 2 || (maj === 2 && min >= 26)
|
||||
? ` authorization:\n credentials_file: ${TOKEN_FILE}\n`
|
||||
: ` bearer_token_file: ${TOKEN_FILE}\n`;
|
||||
}
|
||||
|
||||
function writeConfig(uiPort: string) {
|
||||
if (existsSync(CONF_FILE)) copyFileSync(CONF_FILE, `${CONF_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
||||
writeFileSync(CONF_FILE, `${MARKER}
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: forgetting
|
||||
scheme: http
|
||||
metrics_path: /metrics
|
||||
${authBlock()} static_configs:
|
||||
- targets: ['127.0.0.1:${uiPort}']
|
||||
`, { mode: 0o644 });
|
||||
}
|
||||
|
||||
// Токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
|
||||
function writeToken() {
|
||||
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
|
||||
writeFileSync(TOKEN_FILE, token, { mode: 0o640 });
|
||||
sh("chown", ["root:prometheus", TOKEN_FILE], { quiet: true });
|
||||
writeFileSync(MONITOR_HASH_FILE, crypto.createHash("sha256").update(token).digest("hex") + "\n", { mode: 0o644 });
|
||||
}
|
||||
|
||||
function setArgs() {
|
||||
const line = `ARGS="--web.listen-address=${LISTEN} --storage.tsdb.retention.time=${RETENTION}"`;
|
||||
let text = existsSync(DEFAULTS_FILE) ? readFileSync(DEFAULTS_FILE, "utf8") : "";
|
||||
if (text) copyFileSync(DEFAULTS_FILE, `${DEFAULTS_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
||||
text = /^ARGS=/m.test(text) ? text.replace(/^ARGS=.*$/m, line) : `${text.replace(/\n?$/, "\n")}${line}\n`;
|
||||
writeFileSync(DEFAULTS_FILE, text, { mode: 0o644 });
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor install");
|
||||
if (!/debian|ubuntu/i.test(osFamily())) fail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).");
|
||||
const uiPort = process.env.UI_PORT;
|
||||
if (!uiPort) fail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.");
|
||||
|
||||
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
|
||||
if (installed() && existsSync(CONF_FILE) && !managed()) {
|
||||
fail(`Prometheus уже установлен и настроен не нами (${CONF_FILE}). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.`);
|
||||
}
|
||||
|
||||
log("1/4 установка пакета prometheus");
|
||||
if (!installed()) {
|
||||
if (sh("apt-get", ["update", "-qq"]).status !== 0) fail("apt-get update завершился ошибкой");
|
||||
if (sh("apt-get", ["install", "-y", "--no-install-recommends", "prometheus"]).status !== 0) fail("apt-get install prometheus завершился ошибкой");
|
||||
} else log("уже установлен");
|
||||
|
||||
log("2/4 токен и конфиг");
|
||||
writeToken();
|
||||
writeConfig(uiPort);
|
||||
|
||||
log(`3/4 слушаем только ${LISTEN}, хранение ${RETENTION}`);
|
||||
setArgs();
|
||||
|
||||
log("4/4 запуск");
|
||||
sh("systemctl", ["enable", "prometheus"], { quiet: true });
|
||||
if (sh("systemctl", ["restart", "prometheus"]).status !== 0) fail("prometheus не запустился: journalctl -u prometheus");
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (await ready()) { console.log(`\n ✓ Prometheus работает на ${LISTEN}, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n`); return; }
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
fail("Prometheus не ответил за 30 с: journalctl -u prometheus");
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const ok = await ready();
|
||||
console.log(` пакет: ${installed() ? "установлен" : "нет"}`);
|
||||
console.log(` сервис: ${active() ? "работает" : "остановлен"}`);
|
||||
console.log(` наш конфиг: ${managed() ? "да" : "нет"}`);
|
||||
console.log(` готов: ${ok ? `да, ${LISTEN}` : "нет"}`);
|
||||
}
|
||||
|
||||
function remove() {
|
||||
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor remove");
|
||||
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
|
||||
if (installed() && existsSync(CONF_FILE) && !managed()) {
|
||||
fail(`Prometheus настроен не нами (${CONF_FILE}), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus`);
|
||||
}
|
||||
|
||||
log("1/3 остановка сервиса");
|
||||
sh("systemctl", ["disable", "--now", "prometheus"], { quiet: true });
|
||||
|
||||
log("2/3 токен");
|
||||
for (const f of [TOKEN_FILE, MONITOR_HASH_FILE]) { try { unlinkSync(f); } catch { /* уже нет */ } }
|
||||
|
||||
log("3/3 удаление пакета и накопленных данных");
|
||||
if (installed() && sh("apt-get", ["purge", "-y", "prometheus"]).status !== 0) fail("apt-get purge prometheus завершился ошибкой");
|
||||
|
||||
console.log("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n");
|
||||
}
|
||||
|
||||
export async function monitorCmd(sub?: string) {
|
||||
switch (sub) {
|
||||
case "install": await install(); break;
|
||||
case "status": await status(); break;
|
||||
case "remove": remove(); break;
|
||||
default: console.log("Usage: awg-ctrl monitor <install|status|remove>"); process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/agent"
|
||||
"github.com/maeneko/forgetting/internal/cli"
|
||||
"github.com/maeneko/forgetting/internal/ctrl"
|
||||
"github.com/maeneko/forgetting/internal/relay"
|
||||
"github.com/maeneko/forgetting/internal/ui"
|
||||
)
|
||||
|
||||
// version — из сборки релиза (-ldflags "-X main.version=…").
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// Без аргументов или с командой оператора — CLI (меню, start/stop/…), как прежний awg-ctrl.
|
||||
if len(os.Args) < 2 {
|
||||
cli.Main(nil)
|
||||
return
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
case "ctrl":
|
||||
s, err := ctrl.New()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := s.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "ui":
|
||||
a, err := ui.New()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := a.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "agent":
|
||||
c, err := agent.ConfigFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
a, err := agent.New(c)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
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:
|
||||
cli.Main(os.Args[1:])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
module github.com/maeneko/forgetting
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
modernc.org/sqlite v1.60.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
+1750
-544
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,730 @@
|
||||
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
|
||||
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
|
||||
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
|
||||
// awg-ctrl остаётся закрытым от сети. Если на ноде стоит Prometheus (`awg-ctrl monitor install`),
|
||||
// агент отдаёт ему метрики панели, взятые у core, и пропускает к нему запросы вкладки «Монитор».
|
||||
// Протокол и формат join-строки — docs/node-protocol.md.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/envfile"
|
||||
"github.com/maeneko/forgetting/internal/iauth"
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
const (
|
||||
handshakeTimeout = 10 * time.Second
|
||||
deadAfter = 60 * time.Second // core пингует чаще; тишина дольше — соединение мёртвое
|
||||
maxPayload = 4 * 1024 * 1024
|
||||
ctrlTimeout = 110 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
closeAuthFailed = 4003
|
||||
promTimeout = 8 * time.Second // как у панели к своему Prometheus
|
||||
metricsTimeout = 9 * time.Second // меньше scrape_timeout Prometheus (10 с по умолчанию)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
CoreHost string
|
||||
CorePort int
|
||||
CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым
|
||||
CorePin string // SHA-256 SPKI сертификата core, base64url
|
||||
NodeID int
|
||||
Ctrl string // http://127.0.0.1:PORT
|
||||
NodeKeyFile string
|
||||
InternalKey string
|
||||
CLIEnvFile string
|
||||
JoinSecret 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)
|
||||
}
|
||||
|
||||
// envFromFile — значение KEY=… из .env (как в Node-версии: первая строка, обрезанная).
|
||||
func envFromFile(file, key string) string {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(m[1]))
|
||||
}
|
||||
|
||||
// ConfigFromEnv собирает конфиг так же, как Node-агент. Каталог агента — рабочий каталог
|
||||
// процесса (CLI запускает сервис с cwd = <root>/awg-agent): там node.key по умолчанию, а
|
||||
// .env проекта — уровнем выше.
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
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 {
|
||||
if v, ok := os.LookupEnv(k); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
c := Config{
|
||||
CoreHost: os.Getenv("CORE_HOST"),
|
||||
CorePort: atoi("CORE_PORT"),
|
||||
CorePin: os.Getenv("CORE_PIN"),
|
||||
CoreEndpoint: os.Getenv("CORE_ENDPOINT"),
|
||||
NodeID: nodeID,
|
||||
Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
|
||||
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
|
||||
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
|
||||
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
|
||||
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 == "" {
|
||||
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
|
||||
}
|
||||
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, nil
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
c Config
|
||||
nodeKey ed25519.PrivateKey
|
||||
nodePubRaw string
|
||||
internalKey ed25519.PrivateKey // nil — запросы к awg-ctrl уйдут без токена и получат 401
|
||||
|
||||
mu sync.Mutex
|
||||
joinSecret string
|
||||
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
|
||||
cur int
|
||||
link *coreLink // соединение после ready; nil — core сейчас недоступен
|
||||
|
||||
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): создаётся один раз, приватная часть с ноды не уходит.
|
||||
func loadNodeKey(file string, logf func(string, ...any)) (ed25519.PrivateKey, error) {
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logf("agent: создан ключ ноды %s", file)
|
||||
}
|
||||
return iauth.LoadPrivate(file)
|
||||
}
|
||||
|
||||
func New(c Config) (*Agent, error) {
|
||||
if c.Logf == nil {
|
||||
c.Logf = func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
|
||||
}
|
||||
if c.Errorf == nil {
|
||||
c.Errorf = func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
|
||||
}
|
||||
key, err := loadNodeKey(c.NodeKeyFile, c.Logf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: ключ ноды %s: %w", c.NodeKeyFile, err)
|
||||
}
|
||||
a := &Agent{
|
||||
c: c, nodeKey: key, joinSecret: c.JoinSecret,
|
||||
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
|
||||
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
|
||||
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 {
|
||||
a.internalKey = ik
|
||||
} else {
|
||||
c.Errorf("agent: приватный ключ внутренней авторизации не найден: %s", c.InternalKey)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// authMsg дублируется в awg-ui/nodes.ts.
|
||||
func authMsg(nonce string, nodeID int) []byte {
|
||||
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
|
||||
}
|
||||
|
||||
// preauthMsg — подпись в заголовке запроса на WebSocket: хаб проверяет её до апгрейда и без неё
|
||||
// отвечает 404, как пустой сервер (internal/ui/hub_preauth.go, там же копия).
|
||||
func preauthMsg(nodeID int, ts int64) []byte {
|
||||
return []byte("awg-node-pre-v1\n" + strconv.Itoa(nodeID) + "\n" + strconv.FormatInt(ts, 10))
|
||||
}
|
||||
|
||||
// preauthHeader — подпись ноды (и при первой привязке — секрет с ключом) для запроса на WebSocket.
|
||||
func (a *Agent) preauthHeader() http.Header {
|
||||
ts := time.Now().Unix()
|
||||
h := http.Header{}
|
||||
h.Set("X-Node-Id", strconv.Itoa(a.c.NodeID))
|
||||
h.Set("X-Node-Ts", strconv.FormatInt(ts, 10))
|
||||
h.Set("X-Node-Sig", b64.EncodeToString(ed25519.Sign(a.nodeKey, preauthMsg(a.c.NodeID, ts))))
|
||||
if s := a.secret(); s != "" {
|
||||
h.Set("X-Node-Join", s)
|
||||
h.Set("X-Node-Pub", a.nodePubRaw)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
|
||||
|
||||
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
|
||||
|
||||
var rePath = regexp.MustCompile(`^/[\x21-\x7e]*$`)
|
||||
|
||||
// pathAllowed — агент не открытый прокси: только API самого awg-ctrl. Строже Node-версии
|
||||
// там, где та полагалась на нормализацию WHATWG URL: обратный слэш и «//» в начале
|
||||
// (смена хоста) отвергаются сразу, «.»/«..» — и в %2e-записи.
|
||||
func pathAllowed(p any) (string, bool) {
|
||||
s, ok := p.(string)
|
||||
if !ok || len(s) > 2048 || !rePath.MatchString(s) || strings.HasPrefix(s, "//") || strings.Contains(s, `\`) {
|
||||
return "", false
|
||||
}
|
||||
pathname := s
|
||||
if i := strings.IndexAny(pathname, "?#"); i >= 0 {
|
||||
pathname = pathname[:i]
|
||||
}
|
||||
for _, seg := range strings.Split(pathname, "/") {
|
||||
switch strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(seg, "%2e", "."), "%2E", ".")) {
|
||||
case ".", "..":
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if pathname == "/health" || strings.HasPrefix(pathname, "/api/") || strings.HasPrefix(pathname, "/awg/") {
|
||||
return s, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
type reply struct {
|
||||
status int
|
||||
data any
|
||||
}
|
||||
|
||||
func errReply(status int, msg string) reply {
|
||||
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
|
||||
}
|
||||
|
||||
// execute выполняет запрос core на локальном awg-ctrl (или, для /prom/…, на Prometheus).
|
||||
func (a *Agent) execute(m jsjson.Object) reply {
|
||||
method, _ := m.Get2("method").(string)
|
||||
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"))
|
||||
if !methods[method] || !ok {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
}
|
||||
var body io.Reader
|
||||
if v, has := m.Get("body"); has && v != nil {
|
||||
raw, err := jsjson.Marshal(v)
|
||||
if err != nil {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
u, err := url.Parse(a.c.Ctrl + p)
|
||||
if err != nil {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
}
|
||||
u.Fragment = ""
|
||||
req, err := http.NewRequest(method, u.String(), body)
|
||||
if err != nil {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.internalKey != nil {
|
||||
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
|
||||
}
|
||||
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 {
|
||||
return errReply(502, down)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errReply(502, down)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return reply{resp.StatusCode, ""}
|
||||
}
|
||||
if v, err := jsjson.Decode(raw); err == nil {
|
||||
return reply{resp.StatusCode, v}
|
||||
}
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
func spkiPin(rawCert []byte) (string, error) {
|
||||
cert, err := x509.ParseCertificate(rawCert)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||
return b64.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
var errPin = errors.New("сертификат core не совпал с pin — соединение отклонено")
|
||||
|
||||
// tlsConfig — сертификат core самоподписанный: цепочку не проверяем, доверие даёт pin.
|
||||
// Проверка внутри TLS-рукопожатия: секрет и подпись уходят только тому, чей сертификат
|
||||
// совпал с pin из join-строки.
|
||||
func (a *Agent) tlsConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return errPin
|
||||
}
|
||||
got, err := spkiPin(rawCerts[0])
|
||||
if err != nil || got != a.c.CorePin {
|
||||
return errPin
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// forgetJoinSecret — после первого успешного входа секрет больше не нужен, убираем с диска.
|
||||
func (a *Agent) forgetJoinSecret() {
|
||||
a.mu.Lock()
|
||||
a.joinSecret = ""
|
||||
a.mu.Unlock()
|
||||
os.Unsetenv("JOIN_SECRET")
|
||||
if a.c.CLIEnvFile == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(a.c.CLIEnvFile)
|
||||
if err != nil {
|
||||
return // не критично: секрет одноразовый и с истекающим сроком
|
||||
}
|
||||
var kept []string
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
if !strings.HasPrefix(l, "JOIN_SECRET=") {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.joinSecret
|
||||
}
|
||||
|
||||
// connectOnce держит одно соединение до его закрытия. authFailed — core закрыл с 4003.
|
||||
func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed bool) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var dead *time.Timer
|
||||
var deadMu sync.Mutex
|
||||
armDead := func() {
|
||||
deadMu.Lock()
|
||||
defer deadMu.Unlock()
|
||||
if dead != nil {
|
||||
dead.Stop()
|
||||
}
|
||||
dead = time.AfterFunc(deadAfter, cancel)
|
||||
}
|
||||
defer func() {
|
||||
deadMu.Lock()
|
||||
if dead != nil {
|
||||
dead.Stop()
|
||||
}
|
||||
deadMu.Unlock()
|
||||
}()
|
||||
|
||||
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
|
||||
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
|
||||
ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
|
||||
HTTPClient: &http.Client{Transport: tr},
|
||||
HTTPHeader: a.preauthHeader(),
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
|
||||
})
|
||||
dcancel()
|
||||
if err != nil {
|
||||
// 404 до апгрейда — хаб не принял подпись (или IP временно заблокирован за неудачи):
|
||||
// как отказ в auth, переподключение — не чаще раза в минуту.
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
a.c.Errorf("agent: core отклонил ноду до подключения (неверный ключ, просроченная или использованная ссылка, часы ноды сильно расходятся)")
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, errPin) {
|
||||
a.c.Errorf("agent: %v", errPin)
|
||||
} else {
|
||||
a.c.Errorf("agent: %v", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
defer ws.CloseNow()
|
||||
defer a.setLink(nil)
|
||||
ws.SetReadLimit(maxPayload)
|
||||
|
||||
send := func(v any) {
|
||||
if b, err := jsjson.Marshal(v); err == nil {
|
||||
ws.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
}
|
||||
|
||||
ready := false
|
||||
for {
|
||||
_, raw, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
code := websocket.CloseStatus(err)
|
||||
if !ready && code == -1 && ctx.Err() == nil {
|
||||
a.c.Errorf("agent: %v", err)
|
||||
}
|
||||
if code == closeAuthFailed {
|
||||
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
|
||||
}
|
||||
if code == 4026 {
|
||||
a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err)
|
||||
}
|
||||
return code == closeAuthFailed
|
||||
}
|
||||
v, err := jsjson.Decode(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m, _ := v.(jsjson.Object)
|
||||
switch t, _ := m.Get2("t").(string); {
|
||||
case t == "hello":
|
||||
nonce, ok := m.Get2("nonce").(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
auth := jsjson.Object{
|
||||
{Key: "t", Val: "auth"}, {Key: "node_id", Val: a.c.NodeID},
|
||||
{Key: "sig", Val: b64.EncodeToString(ed25519.Sign(a.nodeKey, authMsg(nonce, a.c.NodeID)))},
|
||||
}
|
||||
if s := a.secret(); s != "" {
|
||||
auth = append(auth, jsjson.KV{Key: "join", Val: jsjson.Object{{Key: "secret", Val: s}, {Key: "pub", Val: a.nodePubRaw}}})
|
||||
}
|
||||
if a.c.Version != "" {
|
||||
auth = append(auth, jsjson.KV{Key: "version", Val: a.c.Version})
|
||||
}
|
||||
send(auth)
|
||||
case t == "ready":
|
||||
ready = true
|
||||
armDead()
|
||||
a.setLink(&coreLink{send: send, done: ctx.Done(), pending: map[int64]chan reply{}})
|
||||
if onReady != nil {
|
||||
onReady()
|
||||
}
|
||||
if a.secret() != "" {
|
||||
a.forgetJoinSecret()
|
||||
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:
|
||||
id, ok := m.Get2("id").(float64)
|
||||
if !ok || id != float64(int64(id)) {
|
||||
continue
|
||||
}
|
||||
// Запросы независимы, как async-обработчики в Node: медленный /awg/… не держит очередь.
|
||||
go func() {
|
||||
r := a.execute(m)
|
||||
send(jsjson.Object{{Key: "t", Val: "res"}, {Key: "id", Val: id}, {Key: "status", Val: r.status}, {Key: "data", Val: r.data}})
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Метрики панели для 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 {
|
||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
return host + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
|
||||
if a.c.MetricsPort > 0 {
|
||||
go a.serveMetrics(ctx)
|
||||
}
|
||||
backoff := time.Second
|
||||
for {
|
||||
ready := false
|
||||
authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true })
|
||||
if !ready {
|
||||
a.next()
|
||||
}
|
||||
wait := backoff
|
||||
if authFailed {
|
||||
wait = maxBackoff
|
||||
}
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/iauth"
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
func TestPathAllowed(t *testing.T) {
|
||||
for p, want := range map[any]bool{
|
||||
"/health": true,
|
||||
"/api/users": true,
|
||||
"/api/users/a?x=1": true,
|
||||
"/awg/upgrade": true,
|
||||
"/healthz": false,
|
||||
"/api": false,
|
||||
"/etc/passwd": false,
|
||||
"//evil.com/api/x": false,
|
||||
`/\evil.com/api/x`: false,
|
||||
"/api/../etc": false,
|
||||
"/api/%2e%2E/etc": false,
|
||||
"/api/./x": false,
|
||||
"/api/a b": false,
|
||||
"api/users": false,
|
||||
42: false,
|
||||
"/api/" + strings.Repeat("a", 2048): false,
|
||||
} {
|
||||
if _, got := pathAllowed(p); got != want {
|
||||
t.Errorf("pathAllowed(%v) = %v, want %v", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeKey(t *testing.T, file string, priv ed25519.PrivateKey) {
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
|
||||
}
|
||||
|
||||
type core struct {
|
||||
srv *httptest.Server
|
||||
frames chan jsjson.Object // всё, что прислал агент
|
||||
script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)
|
||||
}
|
||||
|
||||
func newCore(t *testing.T, script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)) *core {
|
||||
c := &core{frames: make(chan jsjson.Object, 16), script: script}
|
||||
c.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/node/v1" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ws, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ws.CloseNow()
|
||||
ctx := r.Context()
|
||||
read := func() jsjson.Object {
|
||||
_, raw, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v, _ := jsjson.Decode(raw)
|
||||
o, _ := v.(jsjson.Object)
|
||||
c.frames <- o
|
||||
return o
|
||||
}
|
||||
c.script(ctx, ws, read)
|
||||
}))
|
||||
t.Cleanup(c.srv.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *core) pin(t *testing.T) string {
|
||||
p, err := spkiPin(c.srv.Certificate().Raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (c *core) hostPort() (string, int) {
|
||||
u, _ := url.Parse(c.srv.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
return u.Hostname(), port
|
||||
}
|
||||
|
||||
func newAgent(t *testing.T, c *core, pin, ctrlURL string) (*Agent, string) {
|
||||
dir := t.TempDir()
|
||||
host, port := c.hostPort()
|
||||
_, ik, _ := ed25519.GenerateKey(rand.Reader)
|
||||
writeKey(t, filepath.Join(dir, "internal.key"), ik)
|
||||
envFile := filepath.Join(dir, "cli.env")
|
||||
os.WriteFile(envFile, []byte("CORE_HOST=x\nJOIN_SECRET=s3cret\nNODE_ID=7\n"), 0o600)
|
||||
a, err := New(Config{
|
||||
CoreHost: host, CorePort: port, CorePin: pin, NodeID: 7, Ctrl: ctrlURL,
|
||||
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
|
||||
CLIEnvFile: envFile, JoinSecret: "s3cret", Version: "Beta 0.3.4",
|
||||
Logf: t.Logf, Errorf: t.Logf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a, envFile
|
||||
}
|
||||
|
||||
func send(ctx context.Context, ws *websocket.Conn, v any) {
|
||||
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(v))
|
||||
}
|
||||
|
||||
func TestHandshakeAndRequests(t *testing.T) {
|
||||
var internalPub ed25519.PublicKey
|
||||
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !iauth.Verify(internalPub, tok, time.Now()) {
|
||||
w.WriteHeader(401)
|
||||
io.WriteString(w, `{"error":"Неверная авторизация"}`)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
switch r.URL.Path {
|
||||
case "/api/users":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(201)
|
||||
io.WriteString(w, `{"got":`+string(body)+`, "q":"`+r.URL.RawQuery+`"}`)
|
||||
case "/health":
|
||||
io.WriteString(w, "ok")
|
||||
default:
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}))
|
||||
defer ctrl.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: "n0nce"}})
|
||||
auth := read()
|
||||
if auth == nil {
|
||||
return
|
||||
}
|
||||
join, _ := auth.Get2("join").(jsjson.Object)
|
||||
pubRaw, _ := b64.DecodeString(join.Get2("pub").(string))
|
||||
sig, _ := b64.DecodeString(auth.Get2("sig").(string))
|
||||
if !ed25519.Verify(pubRaw, authMsg("n0nce", 7), sig) {
|
||||
ws.Close(4003, "unauthorized")
|
||||
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: "post"},
|
||||
{Key: "path", Val: "/api/users?x=1"}, {Key: "body", Val: jsjson.Object{{Key: "name", Val: "alice"}}}})
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/etc/passwd"}})
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/health"}})
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 4}, {Key: "method", Val: "DELETE"}, {Key: "path", Val: "/api/x"}})
|
||||
for range 4 {
|
||||
if r := read(); r != nil {
|
||||
results <- r
|
||||
}
|
||||
}
|
||||
ws.Close(websocket.StatusNormalClosure, "")
|
||||
})
|
||||
|
||||
a, envFile := newAgent(t, c, c.pin(t), ctrl.URL)
|
||||
internalPub = a.internalKey.Public().(ed25519.PublicKey)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if a.connectOnce(ctx, nil) {
|
||||
t.Fatal("authFailed on a good handshake")
|
||||
}
|
||||
|
||||
auth := <-c.frames
|
||||
want := `{"t":"auth","node_id":7,"sig":"` + auth.Get2("sig").(string) + `","join":{"secret":"s3cret","pub":"` + a.nodePubRaw + `"},"version":"Beta 0.3.4"}`
|
||||
if got := string(jsjson.MustMarshal(auth)); got != want {
|
||||
t.Errorf("auth frame:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
|
||||
byID := map[float64]string{}
|
||||
for range 4 {
|
||||
r := <-results
|
||||
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
|
||||
}
|
||||
for id, want := range map[float64]string{
|
||||
1: `{"t":"res","id":1,"status":201,"data":{"got":{"name":"alice"},"q":"x=1"}}`,
|
||||
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
|
||||
3: `{"t":"res","id":3,"status":200,"data":"ok"}`,
|
||||
4: `{"t":"res","id":4,"status":204,"data":""}`,
|
||||
} {
|
||||
if byID[id] != want {
|
||||
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
|
||||
}
|
||||
}
|
||||
|
||||
// Секрет одноразовый: после ready он исчезает из памяти и из cli.env.
|
||||
if a.secret() != "" {
|
||||
t.Error("join secret still in memory")
|
||||
}
|
||||
if raw, _ := os.ReadFile(envFile); strings.Contains(string(raw), "JOIN_SECRET") || !strings.Contains(string(raw), "NODE_ID=7") {
|
||||
t.Errorf("cli.env after join: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPinSendsNothing(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()
|
||||
})
|
||||
a, _ := newAgent(t, c, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "http://127.0.0.1:1")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if a.connectOnce(ctx, nil) {
|
||||
t.Fatal("pin mismatch reported as auth failure")
|
||||
}
|
||||
select {
|
||||
case f := <-c.frames:
|
||||
t.Fatalf("agent sent %v to a core with a foreign certificate", f)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejected(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()
|
||||
ws.Close(4003, "unauthorized")
|
||||
})
|
||||
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if !a.connectOnce(ctx, nil) {
|
||||
t.Fatal("4003 not reported as auth failure")
|
||||
}
|
||||
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "JOIN_SECRET=s3cret") {
|
||||
t.Error("join secret dropped without a successful join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeKeyPersists(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "node.key")
|
||||
k1, err := loadNodeKey(file, t.Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k2, _ := loadNodeKey(file, t.Logf)
|
||||
if !k1.Equal(k2) {
|
||||
t.Fatal("node key regenerated")
|
||||
}
|
||||
if st, _ := os.Stat(file); st.Mode().Perm() != 0o600 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
// Package cli — менеджер процессов Forgetting (порт cli/src): start/stop/restart/status,
|
||||
// логин и пароль панели, join/unjoin ноды, пиры подписки, встроенный Prometheus и
|
||||
// интерактивное меню. Сервисы — тот же бинарник с ролью: `awg-control ctrl|ui|agent`.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/envfile"
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
// ── Цвета ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
cReset = "\x1b[0m"
|
||||
cBold = "\x1b[1m"
|
||||
cDim = "\x1b[2m"
|
||||
cGreen = "\x1b[32m"
|
||||
cGrey = "\x1b[90m"
|
||||
cBlue = "\x1b[34m"
|
||||
)
|
||||
|
||||
func bold(s string) string { return cBold + s + cReset }
|
||||
func green(s string) string { return cGreen + s + cReset }
|
||||
func grey(s string) string { return cGrey + s + cReset }
|
||||
func dim(s string) string { return cDim + s + cReset }
|
||||
|
||||
func errorf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
|
||||
|
||||
// ── Окружение ───────────────────────────────────────────────────────────────
|
||||
|
||||
// findRoot — корень установки: AWG_ROOT, иначе ближайший предок каталога бинарника, где есть
|
||||
// cli/ или .env, иначе /opt/awg-control.
|
||||
func findRoot() string {
|
||||
if r := os.Getenv("AWG_ROOT"); r != "" {
|
||||
return r
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = real
|
||||
}
|
||||
for d := filepath.Dir(exe); d != "/" && d != "."; d = filepath.Dir(d) {
|
||||
if exists(filepath.Join(d, "cli")) || exists(filepath.Join(d, ".env")) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
return "/opt/awg-control"
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// loadEnvFile — уже заданное окружение не перетираем (??= в Node), поэтому порядок =
|
||||
// приоритет: реальное окружение → cli.env → .env в корне проекта.
|
||||
func loadEnvFile(file string) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(t, "=")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(t[:i])
|
||||
if _, set := os.LookupEnv(k); !set {
|
||||
os.Setenv(k, strings.TrimSpace(t[i+1:]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v, ok := os.LookupEnv(k); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// envOr — `process.env.X || def`.
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
type service struct {
|
||||
name string // ctrl | ui | agent — роль бинарника
|
||||
label string
|
||||
cwd string
|
||||
legacy string // entry Node-версии: её процессы тоже узнаём (переход с TS)
|
||||
env [][2]string
|
||||
pidFile string
|
||||
logFile string
|
||||
}
|
||||
|
||||
type CLI struct {
|
||||
root string
|
||||
logs string
|
||||
envFile string
|
||||
origEnv []string // окружение до чтения cli.env и .env — для свежего перезапуска
|
||||
exe string
|
||||
|
||||
brand, channel, version string
|
||||
|
||||
svc map[string]*service
|
||||
all []string
|
||||
isNode bool
|
||||
|
||||
in *bufio.Reader
|
||||
}
|
||||
|
||||
func New() *CLI {
|
||||
c := &CLI{root: findRoot(), origEnv: os.Environ(), in: bufio.NewReader(os.Stdin)}
|
||||
c.exe, _ = os.Executable()
|
||||
c.logs = filepath.Join(c.root, "logs")
|
||||
os.MkdirAll(c.logs, 0o755)
|
||||
c.envFile = filepath.Join(c.root, "cli", "cli.env")
|
||||
loadEnvFile(c.envFile) // машинные настройки
|
||||
loadEnvFile(filepath.Join(c.root, ".env")) // имя панели и версия
|
||||
|
||||
c.brand = envOr("BRAND", "Forgetting")
|
||||
c.channel = envOr("CHANNEL", "Beta")
|
||||
c.version = envOr("VERSION", "")
|
||||
|
||||
cliEnv := c.envFile
|
||||
coreHost := os.Getenv("CORE_HOST")
|
||||
audit := "off"
|
||||
if coreHost == "" {
|
||||
audit = env("AUDIT", "on")
|
||||
}
|
||||
c.svc = map[string]*service{
|
||||
"awgctrl": {
|
||||
name: "ctrl", label: "awg-ctrl", cwd: filepath.Join(c.root, "awg-ctrl"), legacy: "index.ts",
|
||||
env: [][2]string{
|
||||
{"PORT", env("AWGCTRL_PORT", "3005")},
|
||||
{"SERVER_IP", env("SERVER_IP", "")},
|
||||
{"SERVER_PORT", env("SERVER_PORT", "47619")},
|
||||
{"SERVER_NAME", env("SERVER_NAME", "VPN")},
|
||||
// Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui).
|
||||
{"INTERNAL_AUTH_PUB_FILE", env("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")},
|
||||
},
|
||||
pidFile: "/tmp/awg-ctrl.pid", logFile: filepath.Join(c.logs, "awg-ctrl.log"),
|
||||
},
|
||||
"ui": {
|
||||
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
|
||||
env: [][2]string{
|
||||
{"PORT", env("UI_PORT", "8080")},
|
||||
// Секретный путь панели; _MOVED — путь панели, переехавшей сюда (принимаются оба).
|
||||
{"UI_PREFIX", env("UI_PREFIX", "")},
|
||||
{"UI_PREFIX_MOVED", env("UI_PREFIX_MOVED", "")},
|
||||
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
||||
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
|
||||
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
|
||||
{"UI_USER", env("UI_USER", "admin")},
|
||||
{"UI_PASS", env("UI_PASS", "")},
|
||||
{"JWT_SECRET", env("JWT_SECRET", "")},
|
||||
// ui.db в каталоге данных AWG (вне проекта) — переживает переустановку.
|
||||
{"UI_DB_FILE", env("UI_DB_FILE", "/etc/amnezia/amneziawg/ui.db")},
|
||||
// Подписка sen://: без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
|
||||
{"SUB_PORT", env("SUB_PORT", "")},
|
||||
{"SUB_TLS", env("SUB_TLS", "off")},
|
||||
{"SUB_SIGN_KEY_FILE", env("SUB_SIGN_KEY_FILE", "/etc/amnezia/amneziawg/sub_sign.key")},
|
||||
{"SUB_TLS_KEY_FILE", env("SUB_TLS_KEY_FILE", "/etc/amnezia/amneziawg/sub_tls.key")},
|
||||
{"SUB_TLS_CERT_FILE", env("SUB_TLS_CERT_FILE", "/etc/amnezia/amneziawg/sub_tls.crt")},
|
||||
{"SUB_HOST", env("SUB_HOST", "")},
|
||||
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
||||
{"NODE_PORT", env("NODE_PORT", "")},
|
||||
// Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все).
|
||||
{"HUB_LEGACY", env("HUB_LEGACY", "")},
|
||||
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
|
||||
{"RELAY_FROM", env("RELAY_FROM", "")},
|
||||
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
|
||||
{"TRUST_PROXY", env("TRUST_PROXY", "")},
|
||||
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
|
||||
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
|
||||
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
|
||||
{"CLI_ENV_FILE", cliEnv},
|
||||
// Часовой аудит пиров на всех серверах — только у core.
|
||||
{"AUDIT", audit},
|
||||
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
|
||||
{"LOCAL_NODE", env("LOCAL_NODE", "on")},
|
||||
{"NODE_TLS_KEY_FILE", env("NODE_TLS_KEY_FILE", "/etc/amnezia/amneziawg/node_tls.key")},
|
||||
{"NODE_TLS_CERT_FILE", env("NODE_TLS_CERT_FILE", "/etc/amnezia/amneziawg/node_tls.crt")},
|
||||
},
|
||||
pidFile: "/tmp/awg-ui.pid", logFile: filepath.Join(c.logs, "awg-ui.log"),
|
||||
},
|
||||
// Агент ноды: сам звонит в core и исполняет запросы на локальном awg-ctrl.
|
||||
"agent": {
|
||||
name: "agent", label: "awg-agent", cwd: filepath.Join(c.root, "awg-agent"), legacy: "index.ts",
|
||||
env: [][2]string{
|
||||
{"CORE_HOST", env("CORE_HOST", "")},
|
||||
{"CORE_PORT", env("CORE_PORT", "")},
|
||||
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
|
||||
{"CORE_PIN", env("CORE_PIN", "")},
|
||||
{"NODE_ID", env("NODE_ID", "")},
|
||||
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
|
||||
{"JOIN_SECRET", env("JOIN_SECRET", "")},
|
||||
{"CLI_ENV_FILE", cliEnv},
|
||||
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
||||
{"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"),
|
||||
},
|
||||
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
|
||||
"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 с
|
||||
// LOCAL_NODE=off — панель без своего VPN; иначе — обычная установка. Нода со своей панелью
|
||||
// (join без --no-panel) держит и awg-ui: обе панели ходят в один awg-ctrl.
|
||||
c.isNode = coreHost != ""
|
||||
noLocal := strings.ToLower(env("LOCAL_NODE", "on")) == "off"
|
||||
nodePanel := c.isNode && os.Getenv("UI_PASS") != "" && strings.ToLower(env("NODE_PANEL", "on")) != "off"
|
||||
switch {
|
||||
case c.isNode && nodePanel:
|
||||
c.all = []string{"awgctrl", "ui", "agent"}
|
||||
case c.isNode:
|
||||
c.all = []string{"awgctrl", "agent"}
|
||||
case noLocal:
|
||||
c.all = []string{"ui"}
|
||||
default:
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for _, kv := range c.svc[name].env {
|
||||
if kv[0] == "PORT" {
|
||||
return kv[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *CLI) svcEnv(name, key string) string {
|
||||
for _, kv := range c.svc[name].env {
|
||||
if kv[0] == key {
|
||||
return kv[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Процессы ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) readPid(name string) int {
|
||||
raw, err := os.ReadFile(c.svc[name].pidFile)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(firstInt(strings.TrimSpace(string(raw))))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var reLeadInt = regexp.MustCompile(`^[+-]?\d+`)
|
||||
|
||||
// firstInt — parseInt: ведущее целое, остальное игнорируется.
|
||||
func firstInt(s string) string { return reLeadInt.FindString(s) }
|
||||
|
||||
// isAlive — EPERM: процесс есть, просто не наш (считаем живым).
|
||||
func isAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
// pidMatches — защита от переиспользования PID: сверяем cmdline с сервисом. Узнаём и Go-сервис
|
||||
// (`awg-control <роль>`), и процесс Node-версии (entry-файл в cmdline). Без procfs проверить нечем.
|
||||
func (c *CLI) pidMatches(name string, pid int) bool {
|
||||
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
|
||||
s := c.svc[name]
|
||||
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
|
||||
return true
|
||||
}
|
||||
if s.legacy == "" {
|
||||
return false
|
||||
}
|
||||
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
|
||||
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
|
||||
}
|
||||
|
||||
func (c *CLI) isRunning(name string) bool {
|
||||
pid := c.readPid(name)
|
||||
return pid != 0 && isAlive(pid) && c.pidMatches(name, pid)
|
||||
}
|
||||
|
||||
func (c *CLI) start(name string) {
|
||||
s := c.svc[name]
|
||||
if c.isRunning(name) {
|
||||
fmt.Printf(" %s: already running pid=%d\n", s.label, c.readPid(name))
|
||||
return
|
||||
}
|
||||
if name == "awgctrl" {
|
||||
if f := c.svcEnv("awgctrl", "INTERNAL_AUTH_PUB_FILE"); !exists(f) {
|
||||
errorf(" awgctrl: публичный ключ внутренней авторизации не найден: %s", f)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
logF, err := os.OpenFile(s.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
errorf(" %s: не удалось запустить — %v", s.label, err)
|
||||
return
|
||||
}
|
||||
defer logF.Close() // дескриптор унаследован ребёнком — родителю не нужен
|
||||
cmd := exec.Command(c.exe, s.name)
|
||||
cmd.Dir = s.cwd
|
||||
if !exists(s.cwd) {
|
||||
cmd.Dir = c.root
|
||||
}
|
||||
cmd.Env = os.Environ()
|
||||
for _, kv := range s.env {
|
||||
cmd.Env = append(cmd.Env, kv[0]+"="+kv[1])
|
||||
}
|
||||
cmd.Stdout, cmd.Stderr = logF, logF
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detached: переживает CLI
|
||||
if err := cmd.Start(); err != nil {
|
||||
errorf(" %s: не удалось запустить — %v", s.label, err)
|
||||
os.Remove(s.pidFile)
|
||||
return
|
||||
}
|
||||
go cmd.Wait() // пока CLI жив (меню), не оставляем зомби
|
||||
os.WriteFile(s.pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644)
|
||||
fmt.Printf(" %s %s: started pid=%d\n", green("▶"), s.label, cmd.Process.Pid)
|
||||
}
|
||||
|
||||
func (c *CLI) stop(name string) {
|
||||
s := c.svc[name]
|
||||
pid := c.readPid(name)
|
||||
if pid == 0 || !isAlive(pid) || !c.pidMatches(name, pid) {
|
||||
fmt.Printf(" %s: not running\n", s.label)
|
||||
os.Remove(s.pidFile)
|
||||
return
|
||||
}
|
||||
syscall.Kill(pid, syscall.SIGTERM)
|
||||
// Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for isAlive(pid) && !isZombie(pid) && time.Now().Before(deadline) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if isAlive(pid) && !isZombie(pid) {
|
||||
syscall.Kill(pid, syscall.SIGKILL)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
os.Remove(s.pidFile)
|
||||
fmt.Printf(" %s %s: stopped pid=%d\n", grey("■"), s.label, pid)
|
||||
}
|
||||
|
||||
// isZombie — процесс завершился, но его ещё не забрал родитель (наш же Wait в меню).
|
||||
func isZombie(pid int) bool {
|
||||
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := string(raw)
|
||||
if i := strings.LastIndex(s, ")"); i >= 0 && i+2 < len(s) {
|
||||
return s[i+2] == 'Z'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fetchHealth — GET /health и разбор JSON. Любая ошибка — nil.
|
||||
func fetchHealth(url string) any {
|
||||
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
|
||||
resp, err := cl.Get(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var b strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
b.WriteString(string(buf[:n]))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
v, err := jsjson.Decode([]byte(b.String()))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func get(v any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
o, ok := v.(jsjson.Object)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v = o.Get2(k)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func strOr(v any, def string) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (c *CLI) serviceLine(name string, menu bool) string {
|
||||
running := c.isRunning(name)
|
||||
dot, info := grey("○"), grey("stopped")
|
||||
if running {
|
||||
dot = green("●")
|
||||
if menu {
|
||||
info = green("running") + dim(fmt.Sprintf(" pid %d", c.readPid(name)))
|
||||
} else {
|
||||
info = green("running") + fmt.Sprintf(" pid %d", c.readPid(name))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
|
||||
}
|
||||
|
||||
// panelURL — адрес панели с секретным путём; без UI_PORT (нода) — пусто.
|
||||
func panelURL() string {
|
||||
port := env("UI_PORT", "")
|
||||
if port == "" {
|
||||
return ""
|
||||
}
|
||||
host := env("SUB_HOST", env("SERVER_IP", "IP"))
|
||||
if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
u := "http://" + host + ":" + port + "/"
|
||||
if p := env("UI_PREFIX", ""); p != "" {
|
||||
u += p + "/"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *CLI) status() {
|
||||
fmt.Println()
|
||||
for _, n := range c.all {
|
||||
fmt.Println(c.serviceLine(n, false))
|
||||
}
|
||||
if contains(c.all, "ui") {
|
||||
if u := panelURL(); u != "" {
|
||||
fmt.Println(" " + dim("Панель "+u))
|
||||
}
|
||||
}
|
||||
if c.isRunning("awgctrl") {
|
||||
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
|
||||
if gen, ok := get(h, "gen").(string); ok && gen != "" {
|
||||
g := "2.0"
|
||||
if gen == "3.1" {
|
||||
g = "3.1"
|
||||
}
|
||||
fmt.Println(" " + dim(fmt.Sprintf("AmneziaWG %s · модуль %s · tools %s", g, strOr(get(h, "awg", "module"), "?"), strOr(get(h, "awg", "tools"), "?"))))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// probe — любой HTTP-ответ (даже 401/503) значит, что порт слушается.
|
||||
func probe(url string) bool {
|
||||
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
|
||||
resp, err := cl.Get(url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// waitForHealth — ждём, пока awg-ctrl начнёт отвечать; умер — сразу выходим.
|
||||
func (c *CLI) waitForHealth() bool {
|
||||
url := "http://127.0.0.1:" + c.envOf("awgctrl") + "/health"
|
||||
for range 10 {
|
||||
if !c.isRunning("awgctrl") {
|
||||
return false
|
||||
}
|
||||
if probe(url) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *CLI) startAll() {
|
||||
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
|
||||
if !contains(c.all, "awgctrl") {
|
||||
for _, n := range c.all {
|
||||
c.start(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
|
||||
if contains(c.all, "relay") {
|
||||
c.start("relay")
|
||||
}
|
||||
c.start("awgctrl")
|
||||
var rest, labels []string
|
||||
for _, n := range c.all {
|
||||
if n != "awgctrl" && n != "relay" {
|
||||
rest = append(rest, n)
|
||||
labels = append(labels, c.svc[n].label)
|
||||
}
|
||||
}
|
||||
if !c.isRunning("awgctrl") || !c.waitForHealth() {
|
||||
errorf(" %s awg-ctrl не отвечает — %s не запущен (см. %s)", grey("■"), strings.Join(labels, ", "), c.svc["awgctrl"].logFile)
|
||||
return
|
||||
}
|
||||
for _, n := range rest {
|
||||
c.start(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) stopAll() {
|
||||
for _, n := range c.all {
|
||||
c.stop(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) restartAll() {
|
||||
c.stopAll()
|
||||
c.startAll()
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── cli.env ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
|
||||
|
||||
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
|
||||
|
||||
func (c *CLI) readEnvFile() string {
|
||||
raw, _ := os.ReadFile(c.envFile)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// writeEnvFile — новый файл с правами 0600 (в нём пароль панели); у существующего права не трогаем.
|
||||
func (c *CLI) writeEnvFile(content string) error {
|
||||
return os.WriteFile(c.envFile, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// restartFresh — роль читается из окружения при старте процесса, поэтому после смены cli.env
|
||||
// перезапуск — свежим процессом с исходным окружением (без значений из старого cli.env).
|
||||
func (c *CLI) restartFresh() {
|
||||
cmd := exec.Command(c.exe, "restart")
|
||||
cmd.Env = c.origEnv
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
cmd.Run()
|
||||
}
|
||||
|
||||
// ── Ввод ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) ask(prompt string) string {
|
||||
fmt.Print(prompt)
|
||||
line, _ := c.in.ReadString('\n')
|
||||
return strings.TrimRight(line, "\r\n")
|
||||
}
|
||||
|
||||
var reYes = regexp.MustCompile(`(?i)^y`)
|
||||
|
||||
func (c *CLI) confirmYes(q string) bool {
|
||||
return reYes.MatchString(strings.TrimSpace(c.ask(q + " [y/N] ")))
|
||||
}
|
||||
|
||||
// ── Точка входа ─────────────────────────────────────────────────────────────
|
||||
|
||||
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>|update|reinstall|uninstall|panel [path new|off|<путь>]|log [seen] [поиск] [-n N]|metrics [token|revoke]|hub [legacy on|off|auto]|check>"
|
||||
|
||||
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall", "panel", "log", "metrics", "hub", "check"}
|
||||
|
||||
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
||||
func Main(args []string) {
|
||||
c := New()
|
||||
if len(args) == 0 {
|
||||
c.interactiveMenu()
|
||||
return
|
||||
}
|
||||
arg := func(i int) (string, bool) {
|
||||
if i < len(args) {
|
||||
return args[i], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
sub, hasSub := arg(1)
|
||||
a2, hasA2 := arg(2)
|
||||
switch args[0] {
|
||||
case "start":
|
||||
c.startAll()
|
||||
case "stop":
|
||||
c.stopAll()
|
||||
case "restart":
|
||||
c.restartAll()
|
||||
case "status":
|
||||
c.status()
|
||||
case "credentials":
|
||||
switch sub {
|
||||
case "user", "pass":
|
||||
c.setCredentials(sub)
|
||||
default:
|
||||
c.setCredentials("")
|
||||
}
|
||||
case "join":
|
||||
c.joinCore(sub, hasSub, a2, hasA2)
|
||||
case "unjoin":
|
||||
c.unjoinCore()
|
||||
case "rehome":
|
||||
c.rehome(sub, hasSub)
|
||||
case "migrate":
|
||||
c.migrateCmd(sub, a2, hasA2)
|
||||
case "peers":
|
||||
c.peersCmd(sub, hasSub, a2, hasA2)
|
||||
case "monitor":
|
||||
c.monitorCmd(sub)
|
||||
case "panel":
|
||||
c.panelCmd(args[1:])
|
||||
case "log":
|
||||
c.logCmd(args[1:])
|
||||
case "metrics":
|
||||
c.metricsCmd(args[1:])
|
||||
case "hub":
|
||||
c.hubCmd(args[1:])
|
||||
case "check":
|
||||
c.checkCmd()
|
||||
case "update", "reinstall", "uninstall":
|
||||
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
|
||||
c.runInstaller(args[0], false)
|
||||
default:
|
||||
fmt.Println(usage)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpdateEnvVar(t *testing.T) {
|
||||
for _, c := range []struct{ in, key, val, want string }{
|
||||
{"", "A", "1", "\nA=1\n"},
|
||||
{"A=0\nB=2\n", "A", "1", "A=1\nB=2\n"},
|
||||
{"B=2\n\n\n", "A", "1", "B=2\nA=1\n"},
|
||||
{"XA=0\nA=0\n", "A", "$&1", "XA=0\nA=$&1\n"},
|
||||
{"A=0\nA=9\n", "A", "1", "A=1\nA=9\n"}, // как String.replace без g — только первое
|
||||
} {
|
||||
if got := updateEnvVar(c.in, c.key, c.val); got != c.want {
|
||||
t.Errorf("updateEnvVar(%q, %s, %s) = %q, want %q", c.in, c.key, c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := removeEnvVar("A=1\nJOIN_SECRET=x\nB=2\n", "JOIN_SECRET"); got != "A=1\nB=2\n" {
|
||||
t.Errorf("removeEnvVar: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaOf(t *testing.T) {
|
||||
for owner, want := range map[string]string{
|
||||
"abcdef123456/m1/d2": "abcdef123456",
|
||||
"m1/d2": "legacy",
|
||||
"alice": "",
|
||||
"ABCDEF123456/m1/d2": "",
|
||||
"abc/m1/d2": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := areaOf(owner); got != want {
|
||||
t.Errorf("areaOf(%q) = %q, want %q", owner, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeersWord(t *testing.T) {
|
||||
for n, want := range map[int]string{0: "пиров", 1: "пир", 2: "пира", 4: "пира", 5: "пиров", 11: "пиров", 12: "пиров",
|
||||
21: "пир", 22: "пира", 101: "пир", 111: "пиров", 114: "пиров", 1004: "пира"} {
|
||||
if got := peersWord(n); got != want {
|
||||
t.Errorf("peersWord(%d) = %s, want %s", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user