Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6fdacd71c3 |
@@ -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.3.4
|
||||
@@ -0,0 +1,48 @@
|
||||
# Проверка на каждый пуш и 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
|
||||
|
||||
- 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,89 @@
|
||||
# Публикация релиза в 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
|
||||
|
||||
- name: Package archive
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
mkdir "awgcontrol-${VERSION}"
|
||||
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
|
||||
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||
|
||||
# Токен выбираем в 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}"
|
||||
ARCHIVE="awgcontrol-${VERSION}.tar.gz"
|
||||
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; }
|
||||
|
||||
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
|
||||
|
||||
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Проверка на каждый пуш и 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
|
||||
|
||||
- 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
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
mkdir "awgcontrol-${VERSION}"
|
||||
cp -r awg-ctrl awg-ui cli "awgcontrol-${VERSION}/"
|
||||
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
|
||||
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||
|
||||
- name: Publish release
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
cli/cli.env
|
||||
awg-ui/panel.id
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
| Компонент | Роль |
|
||||
|---|---|
|
||||
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
|
||||
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), внешний API (`/api/v1`), прокси к awg-ctrl. Единственный сервис, доступный по сети. |
|
||||
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. |
|
||||
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
|
||||
|
||||
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
|
||||
|
||||
## Требования
|
||||
- **ОС**: 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).
|
||||
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
|
||||
@@ -28,8 +28,184 @@
|
||||
На сервере, от root:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
|
||||
bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
|
||||
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
|
||||
на 2.0 можно так:
|
||||
|
||||
```bash
|
||||
AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
|
||||
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
|
||||
автоматически.
|
||||
|
||||
Источник архива и имя панели не зашиты в код — задаются переменными
|
||||
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
|
||||
(подробности — в комментариях `install.sh` и в `.env`).
|
||||
|
||||
## API
|
||||
|
||||
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
|
||||
стабильный контракт для сторонних программ, авторизация по API-ключу.
|
||||
**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
|
||||
URL в обоих случаях — адрес сервера и порт, выбранный при установке.
|
||||
|
||||
> [!WARNING]
|
||||
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
|
||||
> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет
|
||||
> без 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`. Подробно — `awg-ui/API.md`.
|
||||
|
||||
### Авторизация панели
|
||||
|
||||
```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`. Ниже — роуты, которые
|
||||
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
|
||||
|
||||
### Состояние
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `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`.
|
||||
|
||||
### Интерфейс 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? }] }`; `:id` в путях — id или `uuid` |
|
||||
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
|
||||
| `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 у устройства |
|
||||
|
||||
## Управление
|
||||
|
||||
```bash
|
||||
@@ -42,103 +218,5 @@ awg-ctrl
|
||||
systemctl start|stop|restart awg-control
|
||||
```
|
||||
|
||||
## 1. Публичный API (`/api/v1`)
|
||||
|
||||
Авторизация по API-ключу. Ответы содержат только публичные поля — приватные
|
||||
ключи и PSK наружу не отдаются.
|
||||
|
||||
### Авторизация
|
||||
|
||||
Каждый запрос обязан содержать заголовок:
|
||||
|
||||
```
|
||||
X-Api-Key: awgk_<...>
|
||||
```
|
||||
|
||||
Ключ создаётся в панели (вкладка «API-ключи») или через `POST /ui/apikeys`
|
||||
(раздел 3). Открытое значение ключа показывается **один раз** при создании —
|
||||
сохрани его сразу, дальше доступен только хэш.
|
||||
|
||||
Ответы при проблемах с ключом:
|
||||
|
||||
| Код | Когда |
|
||||
|---|---|
|
||||
| `401 {"error":"API key required"}` | заголовок отсутствует или не начинается с `awgk_` |
|
||||
| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) |
|
||||
|
||||
> Ключ привязан к серверу (`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.3.4",
|
||||
"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"]
|
||||
}
|
||||
+424
-8
@@ -3,6 +3,7 @@
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import { execSync, spawnSync } from "child_process";
|
||||
import * as zlib from "zlib";
|
||||
@@ -86,6 +87,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 +211,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_VERSIONS = readAwgVersions();
|
||||
const CONFIG = {
|
||||
interface: "awg1",
|
||||
confDir: "/etc/amnezia/amneziawg",
|
||||
@@ -216,6 +274,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 +290,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]>(
|
||||
@@ -499,17 +577,22 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
|
||||
|
||||
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 +657,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 +675,126 @@ 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");
|
||||
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() {
|
||||
@@ -652,8 +864,135 @@ 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()}`);
|
||||
}
|
||||
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
|
||||
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, "Пир не найден");
|
||||
stmts.peerDelete.run(pub);
|
||||
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
|
||||
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;
|
||||
db.transaction(() => {
|
||||
for (const p of stmts.peersAll.all()) {
|
||||
if (p.owner.startsWith(prefix)) {
|
||||
if (!want.has(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++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
|
||||
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" });
|
||||
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next));
|
||||
|
||||
function verifyInternalToken(token: string): boolean {
|
||||
try {
|
||||
@@ -686,7 +1025,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 +1039,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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -754,11 +1096,85 @@ 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,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
// Должен стоять ДО 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" });
|
||||
}
|
||||
}));
|
||||
|
||||
app.get("/awg/status", auth, handler((_req, res) => {
|
||||
res.json(getInterfaceStatus());
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.1.4",
|
||||
"version": "0.3.4",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
""
|
||||
"index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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.3.3";
|
||||
|
||||
// ── пользователи (vpn://) ──
|
||||
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
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.3.2" }, // отстаёт от панели
|
||||
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
|
||||
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
|
||||
];
|
||||
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: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
|
||||
const 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 devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
||||
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" } });
|
||||
});
|
||||
|
||||
// ── серверы ──
|
||||
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,
|
||||
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 }));
|
||||
|
||||
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
||||
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
|
||||
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 }); });
|
||||
|
||||
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
|
||||
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 ?? [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 });
|
||||
});
|
||||
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); });
|
||||
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)`));
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
// 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)) {
|
||||
const r = spawnSync("openssl", [
|
||||
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
|
||||
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node",
|
||||
], { 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();
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
if (LOCAL) await refreshHealth(0);
|
||||
const nodes: unknown[] = [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
|
||||
});
|
||||
|
||||
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, onOnline, beforeDelete, afterDelete, defaultId, start };
|
||||
}
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.1.4",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,17 +15,19 @@
|
||||
"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",
|
||||
|
||||
+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; }
|
||||
}
|
||||
+170
-33
@@ -7,6 +7,8 @@ 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";
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
@@ -16,6 +18,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 +53,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 +73,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 +204,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() });
|
||||
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
|
||||
// до выдачи JWT. Наружу уходит только имя продукта и версия.
|
||||
app.get("/ui/brand", (_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 +279,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 +311,74 @@ 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);
|
||||
// Мастер-ключи во внешнем 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")));
|
||||
@@ -253,3 +388,5 @@ app.get("*", (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
|
||||
nodes.start();
|
||||
sub.start();
|
||||
+87
-19
@@ -24,6 +24,9 @@ html, body, #root { height: 100%; }
|
||||
--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;
|
||||
--warn-dot: #e08a2c;
|
||||
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
|
||||
--on-tertiary-container: #7a4a14;
|
||||
|
||||
--danger-border: #e6c4bc;
|
||||
--danger-text: #b34a3a;
|
||||
@@ -65,6 +68,9 @@ html, body, #root { height: 100%; }
|
||||
--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;
|
||||
--warn-dot: #ffb76b;
|
||||
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
|
||||
--on-tertiary-container: #ffdcad;
|
||||
|
||||
--danger-border: #6e4540;
|
||||
--danger-text: #ffb4ab;
|
||||
@@ -373,6 +379,7 @@ body {
|
||||
.server-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.server-card {
|
||||
@@ -387,6 +394,45 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
.server-card--active { border-color: var(--primary-container); }
|
||||
.server-card--inactive { cursor: pointer; }
|
||||
.server-card--inactive:hover { background: var(--state-primary); }
|
||||
.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
|
||||
/* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */
|
||||
.server-add {
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--outline);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.server-add:disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
/* Модалка добавления ноды */
|
||||
.node-scrim {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
background: var(--scrim);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.node-modal {
|
||||
width: 100%; max-width: 520px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.node-modal-cmd {
|
||||
display: block;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--r-table);
|
||||
padding: 12px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.server-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -406,6 +452,23 @@ body {
|
||||
color: var(--neutral-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
||||
.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; }
|
||||
.server-ver-chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--neutral-bg);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
||||
.server-gen-chip {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.server-card-peers {
|
||||
font-size: 12px;
|
||||
color: var(--neutral-text);
|
||||
@@ -466,22 +529,6 @@ body {
|
||||
}
|
||||
.tip-wrap:hover::after { opacity: 1; }
|
||||
|
||||
.server-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1.5px dashed var(--outline);
|
||||
background: transparent;
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
color: var(--neutral-dot);
|
||||
cursor: not-allowed;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -663,14 +710,15 @@ tbody td:last-child { padding-right: 20px; }
|
||||
border-color: var(--primary-container);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.server-card--inactive { border-color: var(--outline-variant); }
|
||||
.server-add { width: 100%; justify-content: center; }
|
||||
.node-modal-actions .btn { flex: 1; }
|
||||
.server-toggle { display: flex; }
|
||||
.server-online-chip { display: none; }
|
||||
|
||||
/* Кнопка «Добавить сервер» — полная ширина */
|
||||
.tip-wrap { width: 100%; }
|
||||
.server-add { width: 100%; justify-content: center; }
|
||||
|
||||
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */
|
||||
/* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
|
||||
.awg-restart { display: none; }
|
||||
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
|
||||
|
||||
@@ -698,3 +746,23 @@ tbody td:last-child { padding-right: 20px; }
|
||||
}
|
||||
.qr-img { width: 100%; height: auto; aspect-ratio: 1; }
|
||||
}
|
||||
|
||||
/* Выбор набора серверов галочками (components/ServerPicker): серверы мастер-ключа,
|
||||
видимость API-ключа */
|
||||
.server-pick {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface-container);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.server-pick input { accent-color: var(--primary); margin: 0; }
|
||||
.server-pick:hover { background: var(--state-primary); }
|
||||
.server-pick--locked { cursor: default; }
|
||||
.server-pick--locked:hover { background: var(--surface-container); }
|
||||
.server-pick-dot { padding: 0; background: none; }
|
||||
.server-pick-name { font-weight: 500; }
|
||||
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
|
||||
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
|
||||
|
||||
+112
-19
@@ -4,24 +4,33 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared';
|
||||
import {
|
||||
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
|
||||
type NodeInfo, type Brand,
|
||||
} from './lib/shared';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||
import ServerBar from './components/ServerBar';
|
||||
import AddServerModal from './components/AddServerModal';
|
||||
import { TABS } 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 [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
||||
const touchStartX = useRef(0);
|
||||
@@ -32,14 +41,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 +95,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 () => {
|
||||
@@ -107,6 +158,25 @@ export default function App() {
|
||||
}
|
||||
}, [startSession]);
|
||||
|
||||
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [token, loadNodes]);
|
||||
|
||||
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
|
||||
// Если не ответил, остаётся фолбэк из констант.
|
||||
useEffect(() => {
|
||||
axios.get('/ui/brand')
|
||||
.then(({ data }) => setBrand({
|
||||
brand: data.brand || BRAND_FALLBACK,
|
||||
channel: data.channel || '',
|
||||
version: data.version || '',
|
||||
}))
|
||||
.catch(() => { /* остаётся фолбэк */ });
|
||||
}, []);
|
||||
|
||||
// Применять и сохранять тему
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -127,7 +197,6 @@ export default function App() {
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [drawerOpen]);
|
||||
|
||||
const isOnline = statusText.startsWith('online:');
|
||||
const statusLabel = statusText.replace(/^(online|offline):/, '');
|
||||
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
|
||||
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
|
||||
@@ -145,7 +214,7 @@ export default function App() {
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
|
||||
{token && showRestart && (
|
||||
{token && showRestart && serverId !== null && (
|
||||
<button
|
||||
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
||||
aria-label="Перезапустить AWG"
|
||||
@@ -160,7 +229,7 @@ export default function App() {
|
||||
{!token ? (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<span className="logo-name">Forgetting</span>
|
||||
<span className="logo-name">{brand.brand}</span>
|
||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||
<input
|
||||
className="field"
|
||||
@@ -213,8 +282,10 @@ export default function App() {
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="sidebar-logo">
|
||||
<span className="logo-name">Forgetting</span>
|
||||
<span className="sidebar-version">Alpha 0.1.4</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 => (
|
||||
@@ -239,20 +310,42 @@ export default function App() {
|
||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
||||
{showServerBar && (
|
||||
<ServerBar
|
||||
serverInfo={serverInfo}
|
||||
serverOnline={isOnline}
|
||||
nodes={nodes}
|
||||
serverId={serverId}
|
||||
hubEnabled={hubEnabled}
|
||||
panelVersion={panelVersion}
|
||||
onSelect={pickServer}
|
||||
onAdd={() => setModal({})}
|
||||
onJoin={n => setModal({ node: n })}
|
||||
onDelete={deleteNode}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showAddServer={chrome.addServer !== false}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
)}
|
||||
<tab.Page token={token} showMsg={showMsg} />
|
||||
{serverId === null && showServerBar ? (
|
||||
<p className="login-sub">
|
||||
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
|
||||
</p>
|
||||
) : (
|
||||
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
|
||||
<tab.Page key={`${tab.id}:${serverId}`} 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">
|
||||
{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>
|
||||
)}
|
||||
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,130 @@
|
||||
// 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 } from '../lib/shared';
|
||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
||||
|
||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
||||
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
|
||||
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
|
||||
function versionChip(n: NodeInfo, panel: string | null) {
|
||||
if (n.pending) return null;
|
||||
let tip = '', warn = false;
|
||||
if (!n.version) {
|
||||
warn = true;
|
||||
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
|
||||
} else if (panel && !n.local) {
|
||||
const d = cmpVersion(n.version, panel);
|
||||
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
|
||||
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
|
||||
}
|
||||
const chip = (
|
||||
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}>
|
||||
{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,
|
||||
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;
|
||||
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); } }}
|
||||
>
|
||||
{/* toggle — виден на мобильном вместо чипа */}
|
||||
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
|
||||
<div className="server-toggle-thumb" />
|
||||
</div>
|
||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
||||
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
|
||||
<span className={`chip chip--${n.online ? '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>
|
||||
<span className="server-card-name">{h?.server || n.name}</span>
|
||||
{n.pending ? (
|
||||
<span className="server-card-ip">ждёт подключения</span>
|
||||
) : !n.online ? (
|
||||
<span className="server-card-ip">нет связи</span>
|
||||
) : h && (
|
||||
<span className="server-card-ip">{h.ip}</span>
|
||||
)}
|
||||
{showAddServer && (
|
||||
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
|
||||
<button className="server-add" disabled>
|
||||
{/* Плашки: поколение 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.local && (
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
aria-label={`Удалить сервер ${n.name}`}
|
||||
onClick={e => { e.stopPropagation(); onDelete(n); }}
|
||||
>
|
||||
<IcoTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<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}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -35,6 +35,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"/>
|
||||
|
||||
@@ -21,23 +21,70 @@ export interface ServerInfo {
|
||||
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;
|
||||
}
|
||||
// Имя панели, канал и версия приходят с сервера (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';
|
||||
|
||||
// Сервер (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
|
||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||
}
|
||||
|
||||
// Сравнение версий вида 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,3 +75,18 @@ LICENSE file in the root directory of this source tree.
|
||||
@media (max-width: 640px) {
|
||||
.apikey-cards-list { display: flex; }
|
||||
}
|
||||
|
||||
/* Настройки ключа: какие серверы он видит */
|
||||
.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: 16px;
|
||||
padding: 12px 16px 14px;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
|
||||
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
// 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 { 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). Открытый ключ показывается
|
||||
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
|
||||
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
|
||||
// серверов — ключ будет привязываться к выбранному в сервер-баре.
|
||||
// Ключ видит набор серверов: на них он заводит 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 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 r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
@@ -44,8 +72,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
|
||||
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
|
||||
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
@@ -68,6 +95,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Серверы</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
@@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<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>
|
||||
@@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<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>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ICONS } from '../components/icons';
|
||||
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
||||
export interface TabChrome {
|
||||
serverBar?: boolean;
|
||||
addServer?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
// 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 QRCode from 'qrcode';
|
||||
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||
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;
|
||||
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||
}
|
||||
|
||||
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
|
||||
// панель догонит, когда нода появится.
|
||||
const syncLabel = (d: Device): string =>
|
||||
d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : '';
|
||||
|
||||
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
|
||||
const platformLabel = (d: Device): string =>
|
||||
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
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[]>([]); // серверы, которые можно включить в ключ
|
||||
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); // узел с открытыми настройками
|
||||
const [limitEdit, setLimitEdit] = useState('');
|
||||
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
|
||||
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
|
||||
setDevs(Object.fromEntries(all));
|
||||
} catch { 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()) return;
|
||||
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), ...(ready.length ? { servers: ready } : {}),
|
||||
});
|
||||
setLabel('');
|
||||
}, 'Мастер-ключ создан');
|
||||
}, [label, limit, nodes, token, act]);
|
||||
|
||||
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);
|
||||
const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' });
|
||||
setModal({ label: k.label, link, tls: t, dataUrl });
|
||||
} 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 extra = list.length - limit;
|
||||
if (Number.isInteger(limit) && extra > 0) {
|
||||
const names = [...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(', ');
|
||||
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) 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>
|
||||
);
|
||||
|
||||
const actions = (k: MasterKey) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
|
||||
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button>
|
||||
<button className="btn-icon" title="Настройки мастер-ключа" aria-label="Настройки мастер-ключа" aria-expanded={tools === k.id} onClick={() => openTools(k)}><IcoSettings /></button>
|
||||
<button className="btn-icon btn-icon--danger" title="Удалить мастер-ключ" aria-label="Удалить мастер-ключ" onClick={() => remove(k)}><IcoTrash /></button>
|
||||
</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' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`}
|
||||
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 deviceActions = (d: Device) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
|
||||
try {
|
||||
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
|
||||
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}}><IcoRefresh /></button>
|
||||
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
|
||||
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
|
||||
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
|
||||
}}><IcoTrash /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
|
||||
// полностью — в подсказке, по клику копируется.
|
||||
const uuidCell = (k: MasterKey) => (
|
||||
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
||||
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
|
||||
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
<code className="mk-device-id">{k.uuid}</code>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
const caret = (k: MasterKey) => (
|
||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||
onClick={() => fold(k.id)}>▾</button>
|
||||
);
|
||||
|
||||
const deviceNode = (k: MasterKey, d: Device) => (
|
||||
<li className="mk-node mk-node--device" key={d.id}>
|
||||
<div className="mk-device">
|
||||
<div className="mk-device-main">
|
||||
<span className="mk-device-title">
|
||||
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
|
||||
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
||||
</span>
|
||||
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
||||
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
|
||||
</div>
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||
</span>
|
||||
<div className="mk-device-traffic">
|
||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
||||
</div>
|
||||
{deviceActions(d)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!enabled && (
|
||||
<p className="mk-notice">Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.</p>
|
||||
)}
|
||||
{enabled && !tls && (
|
||||
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
|
||||
)}
|
||||
|
||||
<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}><IcoPlus /> Создать ключ</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */}
|
||||
<div className="table-card mk-table-card">
|
||||
<table className="mk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
|
||||
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={9} 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 />{k.label}</span></td>
|
||||
<td className="td-mono">{uuidCell(k)}</td>
|
||||
<td colSpan={7}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const open = !folded.has(k.id);
|
||||
return (
|
||||
<Fragment key={k.id}>
|
||||
<tr className="mk-tr-master">
|
||||
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
||||
<td className="td-mono">{uuidCell(k)}</td>
|
||||
<td colSpan={5}></td>
|
||||
<td>{meter(k, list.length)}</td>
|
||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
|
||||
{open && list.map((d, i) => (
|
||||
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
|
||||
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
|
||||
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
|
||||
<td>{platformLabel(d)}</td>
|
||||
<td>
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="td-mono">{bytes(d.rx)}</td>
|
||||
<td className="td-mono">{bytes(d.tx)}</td>
|
||||
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
||||
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
|
||||
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
|
||||
<td></td>
|
||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{open && free > 0 && (
|
||||
<tr className="mk-tr-free mk-last">
|
||||
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</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">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{deletingChip(k)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const isFolded = folded.has(k.id);
|
||||
return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
{caret(k)}
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{meter(k, list.length)}
|
||||
{actions(k)}
|
||||
</div>
|
||||
{tools === k.id && toolbox(k)}
|
||||
{!isFolded && (
|
||||
<ul className="mk-children">
|
||||
{list.map(d => deviceNode(k, d))}
|
||||
{free > 0 && (
|
||||
<li className="mk-node mk-node--free"><div className="mk-free">Свободно: {free}</div></li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{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--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
|
||||
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
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: 13px;
|
||||
color: var(--error-text);
|
||||
background: var(--error-bg);
|
||||
border-radius: 16px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
|
||||
.mk-limit-input { width: 88px !important; }
|
||||
|
||||
/* ── таблица (десктоп) ── */
|
||||
.mk-table td, .mk-table th { white-space: nowrap; }
|
||||
.mk-table .td-actions { width: 150px; }
|
||||
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
|
||||
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
|
||||
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
|
||||
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
|
||||
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
|
||||
.mk-uuid-btn {
|
||||
background: none; border: 0; padding: 0; margin: 0;
|
||||
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
|
||||
}
|
||||
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
|
||||
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
|
||||
.mk-master-uuid {
|
||||
display: block;
|
||||
font-family: var(--mono); font-size: 12px; font-weight: 400;
|
||||
color: var(--neutral-text);
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mk-name .mk-caret { margin-left: -8px; }
|
||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
||||
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
|
||||
.mk-tr-tools:hover { background: transparent; }
|
||||
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
|
||||
.mk-flag { color: var(--neutral-text); font-size: 12px; }
|
||||
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
|
||||
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
|
||||
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
|
||||
}
|
||||
.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; }
|
||||
.mk-indent { position: relative; display: inline-block; padding-left: 36px; }
|
||||
.mk-indent::before {
|
||||
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
|
||||
border-top: 2px solid var(--outline);
|
||||
}
|
||||
.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; }
|
||||
.mk-tr-free:hover { background: transparent; }
|
||||
|
||||
/* ── дерево (мобильный) ── */
|
||||
.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; }
|
||||
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
|
||||
|
||||
.mk-root {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 12px 18px 14px;
|
||||
}
|
||||
|
||||
/* родитель */
|
||||
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
|
||||
.mk-master-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
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: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.mk-caret:hover { background: var(--state-neutral); }
|
||||
.mk-caret--folded { transform: rotate(-90deg); }
|
||||
|
||||
.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: 16px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
|
||||
.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: 14px; font-weight: 500; color: var(--on-surface); }
|
||||
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.mk-uuid {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
|
||||
}
|
||||
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
|
||||
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||
|
||||
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
|
||||
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
|
||||
.mk-meter {
|
||||
--mk-w: 136px;
|
||||
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
|
||||
--meter-on: #2a2118; /* цвет числа поверх заливки */
|
||||
position: relative;
|
||||
width: var(--mk-w); height: 20px;
|
||||
border-radius: var(--r-pill);
|
||||
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 0.25s, background 0.25s;
|
||||
}
|
||||
/* числа по краям внутри полоски: текущее слева, лимит справа */
|
||||
.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: 12px; font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.mk-meter-text--on { color: var(--meter-on); }
|
||||
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */
|
||||
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
|
||||
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
|
||||
|
||||
.mk-box .tip-wrap { width: 100%; }
|
||||
|
||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); }
|
||||
.mk-node { position: relative; padding: 4px 0; }
|
||||
.mk-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -28px; top: 50%;
|
||||
width: 28px;
|
||||
border-top: 2px solid var(--outline);
|
||||
}
|
||||
/* последний узел: линия родителя обрывается на его «ножке» */
|
||||
.mk-node:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -30px; top: calc(50% + 1px); bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--surface-bright);
|
||||
}
|
||||
|
||||
.mk-device {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--surface-container);
|
||||
border-radius: 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; }
|
||||
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.mk-device-id {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--neutral-text);
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 1px 8px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-metric { display: inline-flex; gap: 4px; }
|
||||
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
|
||||
|
||||
.mk-free {
|
||||
border: 1px dashed var(--outline);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--neutral-dot);
|
||||
}
|
||||
|
||||
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
|
||||
50/50, действия рядом с большим пальцем ── */
|
||||
@media (max-width: 640px) {
|
||||
.mk-tree { display: flex; }
|
||||
.mk-limit-input { width: 100% !important; }
|
||||
|
||||
.mk-root { padding: 12px; }
|
||||
|
||||
/* родитель: шапка, затем ряд действий на всю ширину */
|
||||
.mk-master { flex-wrap: wrap; row-gap: 10px; }
|
||||
.mk-master .actions { width: 100%; gap: 8px; }
|
||||
.mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
|
||||
.mk-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; }
|
||||
|
||||
/* дерево уже: отступы поменьше, «ножки» короче */
|
||||
.mk-children { margin-left: 8px; padding-left: 14px; }
|
||||
.mk-node { padding: 4px 0; }
|
||||
.mk-node::before { left: -14px; width: 14px; }
|
||||
.mk-node:last-child::after { left: -16px; }
|
||||
|
||||
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
|
||||
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
|
||||
.mk-device-main { flex: 1 1 0; }
|
||||
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
|
||||
.mk-device-id { max-width: 100%; }
|
||||
.mk-device-traffic { width: 100%; gap: 8px; }
|
||||
.mk-metric {
|
||||
flex: 1; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-bright);
|
||||
border-radius: 16px; padding: 8px 12px;
|
||||
}
|
||||
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-metric-name::after { content: attr(data-sfx); }
|
||||
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
/* кнопки действий: контурные, на всю ширину ряда, 44px */
|
||||
.mk-device .actions { width: 100%; gap: 8px; }
|
||||
.mk-master .btn-icon, .mk-device .btn-icon {
|
||||
flex: 1; width: auto; height: 44px;
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); }
|
||||
|
||||
.mk-free { padding: 12px; font-size: 13px; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "masterkeys",
|
||||
"label": "Мастер-ключи",
|
||||
"icon": "key",
|
||||
"order": 1.5
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
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 { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||
@@ -19,6 +19,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);
|
||||
|
||||
@@ -50,7 +51,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 +63,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 });
|
||||
@@ -120,6 +143,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})
|
||||
@@ -162,11 +190,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
{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 +235,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>
|
||||
|
||||
+967
@@ -0,0 +1,967 @@
|
||||
// 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://-подписка: реестр мастер-ключей/устройств (ui.db), админ-роуты панели
|
||||
// (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на
|
||||
// отдельном порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md.
|
||||
//
|
||||
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
|
||||
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
|
||||
//
|
||||
// Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный
|
||||
// awg-ctrl (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он
|
||||
// становится пиром на каждом сервере набора — IP и PSK на каждом свои (device_peers).
|
||||
// Нода может быть не в сети, поэтому изменения не «толкаются», а сводятся: device_peers
|
||||
// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить,
|
||||
// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться
|
||||
// (сразу при действии, при подключении ноды и раз в минуту).
|
||||
//
|
||||
// Каждый проход заканчивается сведением пиров панели (PUT /api/peers/sync): awg-ctrl
|
||||
// получает полный список ключей, которые этой панели нужны на сервере, и снимает из её
|
||||
// области всё остальное. Это страховка от пиров, чья судьба потерялась (ответ ноды не
|
||||
// дошёл, устройство удалили посреди прохода). Область — id панели в owner пира
|
||||
// («<panel>/m1/d2»): у ноды может быть своя панель на том же awg-ctrl, чужих не трогаем.
|
||||
// Удаляем только по такому списку, никогда по времени: панель не на связи — ничего не
|
||||
// происходит, неактивные устройства никто не снимает.
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import path from "path";
|
||||
import express, { Request, Response, NextFunction, Router } from "express";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
encodeSenLink, rawPublicKey, publicKeyFromRaw, spkiPin,
|
||||
signResponse, verifyRequest,
|
||||
} from "./sen";
|
||||
|
||||
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
|
||||
|
||||
/** Что подписке нужно от реестра серверов (awg-ui/nodes.ts). */
|
||||
export interface Servers {
|
||||
ctrlFor(id: number): Ctrl;
|
||||
exists(id: number): boolean;
|
||||
isOnline(id: number): boolean;
|
||||
ids(): number[];
|
||||
defaultId(): number;
|
||||
onOnline(cb: (id: number) => void): void;
|
||||
beforeDelete(cb: (id: number, force: boolean) => Promise<{ error: string; forcible: boolean } | null>): void;
|
||||
afterDelete(cb: (id: number) => void): void;
|
||||
}
|
||||
|
||||
interface MasterRow {
|
||||
id: number; uuid: string; label: string; secret: Buffer; device_limit: number;
|
||||
server_id: number; created_at: number; revoked_at: number | null;
|
||||
}
|
||||
interface DeviceRow {
|
||||
id: number; master_id: number; device_id: string; device_name: string; platform: string;
|
||||
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: number; version: string;
|
||||
}
|
||||
interface LinkRow {
|
||||
device_id: number; server_id: number; peer_pub: string | null;
|
||||
ip: string | null; psk: string | null; psk_pending: number;
|
||||
}
|
||||
interface Profile {
|
||||
name: string; endpoint: string; server_pub: string; gen: string;
|
||||
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
|
||||
}
|
||||
|
||||
const TS_WINDOW = 300; // секунд, ±
|
||||
const DEFAULT_LIMIT = 3;
|
||||
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
|
||||
const SYNC_EVERY = 60_000; // фоновое сведение серверов
|
||||
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
|
||||
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
|
||||
|
||||
// pub_key WireGuard в пути к awg-ctrl — base64url (в обычном base64 есть «/»).
|
||||
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
|
||||
|
||||
// ── Простой лимитер по IP ──────────────────────────────────────────────────
|
||||
// onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало.
|
||||
function makeLimiter(max: number, windowMs: number, onLimited?: (ip: string) => void) {
|
||||
const hits = new Map<string, { n: number; reset: number }>();
|
||||
return (ip: string): boolean => {
|
||||
const now = Date.now();
|
||||
const e = hits.get(ip);
|
||||
if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; }
|
||||
if (e.n >= max) {
|
||||
if (e.n === max) { e.n++; onLimited?.(ip); }
|
||||
return false;
|
||||
}
|
||||
e.n++;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) {
|
||||
const { uidb, servers } = deps;
|
||||
|
||||
// ── Настройка из окружения ─────────────────────────────────────────────
|
||||
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
|
||||
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
|
||||
// Публичный адрес панели для ссылок и endpoints. Без него — адрес локального сервера,
|
||||
// а у панели без своего VPN — тот, по которому к ней пришли.
|
||||
const SUB_HOST = (process.env.SUB_HOST ?? "").trim();
|
||||
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
|
||||
|
||||
let signKey: crypto.KeyObject | null = null;
|
||||
let signPub: Buffer | null = null;
|
||||
try {
|
||||
signKey = crypto.createPrivateKey(fs.readFileSync(file(process.env.SUB_SIGN_KEY_FILE, "sub_sign.key")));
|
||||
signPub = rawPublicKey(crypto.createPublicKey(signKey));
|
||||
} catch {
|
||||
console.warn("sub: ключ подписи не найден — sen://-подписка отключена");
|
||||
}
|
||||
|
||||
let tlsMaterial: { key: Buffer; cert: Buffer } | null = null;
|
||||
let tlsPin: Buffer | null = null;
|
||||
if (SUB_TLS) {
|
||||
try {
|
||||
tlsMaterial = {
|
||||
key: fs.readFileSync(file(process.env.SUB_TLS_KEY_FILE, "sub_tls.key")),
|
||||
cert: fs.readFileSync(file(process.env.SUB_TLS_CERT_FILE, "sub_tls.crt")),
|
||||
};
|
||||
tlsPin = spkiPin(tlsMaterial.cert.toString());
|
||||
} catch {
|
||||
console.warn("sub: SUB_TLS=on, но сертификат не найден — подписка отключена");
|
||||
signKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Схема ──────────────────────────────────────────────────────────────
|
||||
uidb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS master_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
secret BLOB NOT NULL UNIQUE,
|
||||
device_limit INTEGER NOT NULL DEFAULT ${DEFAULT_LIMIT},
|
||||
server_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
pub_key TEXT NOT NULL UNIQUE,
|
||||
auth_pub TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER,
|
||||
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (master_id, device_id)
|
||||
);
|
||||
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
|
||||
-- дальше не читается (кроме переноса ниже).
|
||||
CREATE TABLE IF NOT EXISTS master_servers (
|
||||
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
|
||||
server_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (master_id, server_id)
|
||||
);
|
||||
-- Пир устройства на сервере. peer_pub — ключ, который сейчас стоит на сервере
|
||||
-- (NULL — ещё не добавлен); расходится с devices.pub_key после смены ключа, пока
|
||||
-- сервер не догнали. ip/psk — как их выдал awg-ctrl этого сервера.
|
||||
CREATE TABLE IF NOT EXISTS device_peers (
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
server_id INTEGER NOT NULL,
|
||||
peer_pub TEXT,
|
||||
ip TEXT,
|
||||
psk TEXT,
|
||||
psk_pending INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (device_id, server_id)
|
||||
);
|
||||
-- Пиры, которые надо удалить с сервера, когда до него дотянемся.
|
||||
CREATE TABLE IF NOT EXISTS peer_tombstones (
|
||||
server_id INTEGER NOT NULL,
|
||||
pub_key TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, pub_key)
|
||||
);
|
||||
-- Последний известный профиль сервера: config собирается из него, даже когда
|
||||
-- нода не в сети, — иначе сервер пропадал бы из подписки на время обрыва.
|
||||
CREATE TABLE IF NOT EXISTS server_profiles (
|
||||
server_id INTEGER PRIMARY KEY,
|
||||
profile TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sub_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
|
||||
// таблицы колонки может не быть.
|
||||
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
||||
// uuid мастер-ключа — постоянный идентификатор: не меняется ни при перевыпуске ссылки,
|
||||
// ни при правке метки. Существующим ключам выдаётся здесь один раз.
|
||||
if (!(uidb.prepare("PRAGMA table_info(master_keys)").all() as { name: string }[]).some(c => c.name === "uuid"))
|
||||
uidb.exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT");
|
||||
{
|
||||
const setUuid = uidb.prepare<[string, number]>("UPDATE master_keys SET uuid = ? WHERE id = ?");
|
||||
for (const { id } of uidb.prepare("SELECT id FROM master_keys WHERE uuid IS NULL").all() as { id: number }[])
|
||||
setUuid.run(crypto.randomUUID(), id);
|
||||
}
|
||||
uidb.exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)");
|
||||
uidb.pragma("foreign_keys = ON");
|
||||
|
||||
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
|
||||
// уже стоит там под devices.pub_key (ip/psk подтянет reconcile). У ключа всегда есть хотя
|
||||
// бы один сервер, поэтому «ни одной строки» бывает только у ключей из старой схемы.
|
||||
uidb.exec(`
|
||||
INSERT OR IGNORE INTO master_servers (master_id, server_id)
|
||||
SELECT id, server_id FROM master_keys
|
||||
WHERE revoked_at IS NULL AND id NOT IN (SELECT master_id FROM master_servers);
|
||||
INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub)
|
||||
SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id
|
||||
WHERE d.id NOT IN (SELECT device_id FROM device_peers);
|
||||
`);
|
||||
|
||||
// Id панели — область её пиров на серверах. Живёт, пока живёт ui.db; копия в panel.id
|
||||
// нужна CLI (у него нет SQLite), чтобы отличать пиров этой панели на awg-ctrl.
|
||||
uidb.prepare("INSERT OR IGNORE INTO sub_meta (key, value) VALUES ('panel_id', ?)").run(crypto.randomBytes(6).toString("hex"));
|
||||
const panelId = (uidb.prepare("SELECT value FROM sub_meta WHERE key = 'panel_id'").get() as { value: string }).value;
|
||||
try { fs.writeFileSync(path.join(deps.baseDir, "panel.id"), panelId + "\n"); }
|
||||
catch (e) { console.warn(`sub: не удалось записать panel.id: ${(e as Error).message}`); }
|
||||
|
||||
const q = {
|
||||
// И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление.
|
||||
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id"),
|
||||
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
|
||||
masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"),
|
||||
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
|
||||
masterInsert: uidb.prepare<[string, string, Buffer, number, number, number]>("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
|
||||
masterIdByUuid: uidb.prepare<[string]>("SELECT id FROM master_keys WHERE uuid = ?"),
|
||||
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
|
||||
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
|
||||
masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"),
|
||||
// Сервер сведён и надгробий на нём нет — отозванным ключам он больше не нужен.
|
||||
revokedServerDone: uidb.prepare<[number]>(
|
||||
"DELETE FROM master_servers WHERE server_id = ? AND master_id IN (SELECT id FROM master_keys WHERE revoked_at IS NOT NULL)"),
|
||||
revokedGone: uidb.prepare("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)"),
|
||||
liveOnServer: uidb.prepare<[number]>(
|
||||
"SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL"),
|
||||
revokedOnServer: uidb.prepare<[number]>(
|
||||
"SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL"),
|
||||
serverRelease: uidb.prepare<[number]>("DELETE FROM master_servers WHERE server_id = ?"),
|
||||
serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"),
|
||||
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"),
|
||||
serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"),
|
||||
usedServers: uidb.prepare("SELECT server_id FROM master_servers UNION SELECT server_id FROM peer_tombstones"),
|
||||
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
|
||||
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
|
||||
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"),
|
||||
deviceCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?"),
|
||||
deviceInsert: uidb.prepare<[number, string, string, string, string, string, number, string]>(
|
||||
"INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
|
||||
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
|
||||
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"),
|
||||
devicePub: uidb.prepare<[string, number, number]>("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?"),
|
||||
deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"),
|
||||
linksOf: uidb.prepare<[number]>("SELECT * FROM device_peers WHERE device_id = ? ORDER BY server_id"),
|
||||
linkAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)"),
|
||||
linkDel: uidb.prepare<[number, number]>("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?"),
|
||||
linkSet: uidb.prepare<[string | null, string | null, string | null, number, number, number]>(
|
||||
"UPDATE device_peers SET peer_pub = ?, ip = ?, psk = ?, psk_pending = ? WHERE device_id = ? AND server_id = ?"),
|
||||
linkPskDue: uidb.prepare<[number]>("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL"),
|
||||
// Что на сервере расходится с нужным: пира нет, стоит старый ключ, не знаем ip/psk, ждёт новый PSK.
|
||||
linksToFix: uidb.prepare<[number]>(`
|
||||
SELECT l.*, d.pub_key AS dev_pub, d.master_id FROM device_peers l JOIN devices d ON d.id = l.device_id
|
||||
WHERE l.server_id = ? AND (l.peer_pub IS NULL OR l.peer_pub <> d.pub_key
|
||||
OR l.ip IS NULL OR l.psk IS NULL OR l.psk_pending = 1)`),
|
||||
tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"),
|
||||
tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"),
|
||||
tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"),
|
||||
tombCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM peer_tombstones WHERE server_id = ?"),
|
||||
// Что панели нужно на сервере: текущий ключ каждого устройства и ключ, который там стоит
|
||||
// сейчас (расходится с текущим, пока смена ключа не догнала сервер).
|
||||
keepOf: uidb.prepare<[number, number]>(`
|
||||
SELECT d.pub_key AS pub FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ?
|
||||
UNION SELECT peer_pub FROM device_peers WHERE server_id = ? AND peer_pub IS NOT NULL`),
|
||||
// Связи, которые считаются сведёнными: на сервере должен стоять текущий ключ устройства.
|
||||
linksSettled: uidb.prepare<[number]>(`
|
||||
SELECT l.device_id, l.peer_pub FROM device_peers l JOIN devices d ON d.id = l.device_id
|
||||
WHERE l.server_id = ? AND l.peer_pub = d.pub_key`),
|
||||
profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"),
|
||||
profileSet: uidb.prepare<[number, string, number]>(
|
||||
"INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"),
|
||||
};
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
|
||||
const serverIdsOf = (masterId: number) => (q.serversOf.all(masterId) as { server_id: number }[]).map(r => r.server_id);
|
||||
|
||||
// ── Сведение серверов ──────────────────────────────────────────────────
|
||||
function storedProfile(id: number): Profile | null {
|
||||
const row = q.profileGet.get(id) as { profile: string } | undefined;
|
||||
try { return row ? JSON.parse(row.profile) as Profile : null; } catch { return null; }
|
||||
}
|
||||
|
||||
async function refreshProfile(id: number, ctrl: Ctrl): Promise<boolean> {
|
||||
const r = await ctrl("GET", "/api/profile");
|
||||
if (!hasCtrlOk(r.status) || typeof r.data?.endpoint !== "string") return false;
|
||||
const json = JSON.stringify(r.data);
|
||||
const row = q.profileGet.get(id) as { profile: string } | undefined;
|
||||
if (row?.profile !== json) q.profileSet.run(id, json, now());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Один проход на сервер за раз: параллельные проходы добавили бы одного пира дважды.
|
||||
const locks = new Map<number, Promise<void>>();
|
||||
function reconcile(id: number): Promise<void> {
|
||||
const next = (locks.get(id) ?? Promise.resolve()).then(() => reconcileNow(id)).catch(e => {
|
||||
console.warn(`sub: сервер #${id} не сведён: ${(e as Error).message}`);
|
||||
});
|
||||
locks.set(id, next);
|
||||
return next;
|
||||
}
|
||||
const reconcileAll = (ids: Iterable<number>) => Promise.all([...new Set(ids)].map(reconcile));
|
||||
|
||||
async function reconcileNow(id: number) {
|
||||
if (!servers.exists(id) || !servers.isOnline(id)) return;
|
||||
const ctrl = servers.ctrlFor(id);
|
||||
if (!await refreshProfile(id, ctrl)) return; // сервер не отвечает — попробуем позже
|
||||
|
||||
// Список пиров сервера — лениво, только если понадобится (409, потерянный ip/psk).
|
||||
let listed: Map<string, { ip: string; psk_key: string }> | null = null;
|
||||
const onServer = async (pub: string) => {
|
||||
if (!listed) {
|
||||
const r = await ctrl("GET", "/api/peers");
|
||||
if (!hasCtrlOk(r.status)) throw new Error(`peers ${r.status}`);
|
||||
listed = new Map((r.data.peers as { pub_key: string; ip: string; psk_key: string }[]).map(p => [p.pub_key, p]));
|
||||
}
|
||||
return listed.get(pub) ?? null;
|
||||
};
|
||||
const gone = (s: number) => s === 502 || s === 504; // связь пропала посреди прохода
|
||||
|
||||
for (const { pub_key } of q.tombsOf.all(id) as { pub_key: string }[]) {
|
||||
const r = await ctrl("DELETE", `/api/peers/${pubParam(pub_key)}`);
|
||||
if (hasCtrlOk(r.status) || r.status === 404) q.tombDel.run(id, pub_key);
|
||||
else if (gone(r.status)) return;
|
||||
}
|
||||
|
||||
type Peer = { ip: string; psk_key: string };
|
||||
for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) {
|
||||
// null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем.
|
||||
const add = async (): Promise<Peer | null | "gone"> => {
|
||||
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `${panelId}/m${l.master_id}/d${l.device_id}` });
|
||||
if (hasCtrlOk(r.status)) return r.data;
|
||||
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
|
||||
return gone(r.status) ? "gone" : null;
|
||||
};
|
||||
|
||||
let peer: Peer | null | "gone";
|
||||
if (l.peer_pub === null) {
|
||||
peer = await add();
|
||||
} else if (l.peer_pub !== l.dev_pub) {
|
||||
// Ключ сменили, пока сервер был недоступен: тот же IP и PSK, новый ключ.
|
||||
const r = await ctrl("PUT", `/api/peers/${pubParam(l.peer_pub)}`, { pub_key: l.dev_pub });
|
||||
peer = hasCtrlOk(r.status) ? r.data
|
||||
: r.status === 404 ? await add() // старого пира на сервере уже нет
|
||||
: r.status === 409 ? await onServer(l.dev_pub)
|
||||
: gone(r.status) ? "gone" : null;
|
||||
} else {
|
||||
peer = await onServer(l.dev_pub) ?? await add(); // ip/psk не знаем или пир пропал с сервера
|
||||
}
|
||||
if (peer === "gone") return;
|
||||
if (!peer) continue;
|
||||
|
||||
let pending = 0;
|
||||
if (l.psk_pending === 1) {
|
||||
const r = await ctrl("POST", `/api/peers/${pubParam(l.dev_pub)}/psk`);
|
||||
if (hasCtrlOk(r.status)) peer = r.data as Peer;
|
||||
else if (gone(r.status)) return;
|
||||
else pending = 1; // пир на месте, PSK сменим позже
|
||||
}
|
||||
q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id);
|
||||
}
|
||||
|
||||
await syncPeers(id, ctrl);
|
||||
}
|
||||
|
||||
// Сведение пиров панели на сервере — в конце прохода и под его блокировкой: список строится
|
||||
// из ui.db сейчас, поэтому устройство, удалённое посреди прохода, в него уже не попадёт, и
|
||||
// ключ, поставленный для него этим проходом, снимется здесь же.
|
||||
async function syncPeers(id: number, ctrl: Ctrl) {
|
||||
const keep = (q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub);
|
||||
const r = await ctrl("PUT", "/api/peers/sync", { owner: panelId, keep });
|
||||
if (hasCtrlOk(r.status)) {
|
||||
// Самолечение: ключ считается стоящим, а его нет (откат смены ключа после потерянного
|
||||
// ответа, пир сняли руками) — забываем и ставим заново следующим проходом.
|
||||
const present = new Set(r.data?.present as string[]);
|
||||
let lost = 0;
|
||||
for (const l of q.linksSettled.all(id) as { device_id: number; peer_pub: string }[]) {
|
||||
if (present.has(l.peer_pub)) continue;
|
||||
q.linkSet.run(null, null, null, 0, l.device_id, id);
|
||||
lost++;
|
||||
}
|
||||
if (lost) void reconcile(id);
|
||||
} else if (r.status !== 400 && r.status !== 413) {
|
||||
return; // 400/413 — awg-ctrl старше sync: чистим, как раньше, одними надгробиями
|
||||
}
|
||||
if ((q.tombCount.get(id) as { n: number }).n > 0) return;
|
||||
q.revokedServerDone.run(id);
|
||||
q.revokedGone.run();
|
||||
}
|
||||
|
||||
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
|
||||
function forgetDevice(dev: DeviceRow): number[] {
|
||||
const links = q.linksOf.all(dev.id) as LinkRow[];
|
||||
uidb.transaction(() => {
|
||||
for (const l of links) if (l.peer_pub) q.tombAdd.run(l.server_id, l.peer_pub);
|
||||
q.deviceDelete.run(dev.id); // device_peers уйдут каскадом
|
||||
})();
|
||||
return links.map(l => l.server_id);
|
||||
}
|
||||
|
||||
// ── config ─────────────────────────────────────────────────────────────
|
||||
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
|
||||
|
||||
function subHost(req?: Request): string {
|
||||
if (SUB_HOST) return SUB_HOST;
|
||||
const local = servers.exists(0) ? storedProfile(0) : null;
|
||||
if (local) return hostOf(local.endpoint);
|
||||
return (req?.hostname ?? "").replace(/^\[|\]$/g, "");
|
||||
}
|
||||
|
||||
// В servers[] — только серверы, где уже стоит текущий ключ устройства и известны ip/psk.
|
||||
// Сервер, который ещё не догнали (нода не в сети при регистрации или смене ключа), появится,
|
||||
// когда его сведут, — rev при этом сменится, и клиент подтянет.
|
||||
function buildConfig(dev: DeviceRow, req?: Request) {
|
||||
const list = [];
|
||||
for (const l of q.linksOf.all(dev.id) as LinkRow[]) {
|
||||
const prof = storedProfile(l.server_id);
|
||||
if (!prof || l.peer_pub !== dev.pub_key || !l.ip || !l.psk) continue;
|
||||
list.push({
|
||||
id: l.server_id, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
|
||||
psk: l.psk, address: `${l.ip}/32`, dns: prof.dns,
|
||||
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
|
||||
});
|
||||
}
|
||||
const cfg = {
|
||||
rekey: dev.rekey_requested === 1,
|
||||
endpoints: [`${subHost(req)}:${SUB_PORT}`],
|
||||
servers: list,
|
||||
};
|
||||
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
|
||||
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
|
||||
return { rev, ...cfg };
|
||||
}
|
||||
|
||||
// Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель
|
||||
// из-за временной недоступности нод — пусть лучше живёт со старым конфигом.
|
||||
async function readyConfig(dev: DeviceRow, req: Request) {
|
||||
let cfg = buildConfig(dev, req);
|
||||
if (cfg.servers.length === 0) {
|
||||
await reconcileAll((q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id));
|
||||
cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
|
||||
}
|
||||
if (cfg.servers.length === 0) throw new Error("no servers");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
|
||||
const sub = express();
|
||||
sub.disable("x-powered-by");
|
||||
sub.use(express.json({
|
||||
limit: "4kb",
|
||||
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
|
||||
}));
|
||||
|
||||
const limited = (what: string) => (ip: string) => console.warn(`sub: 429 ${what} ip=${ip} — лимит исчерпан на 15 минут`);
|
||||
const registerLimit = makeLimiter(30, 15 * 60 * 1000, limited("register"));
|
||||
const failLimit = makeLimiter(30, 15 * 60 * 1000, limited("неудачные запросы"));
|
||||
const seenSigs = new Map<string, number>(); // подпись → когда забыть
|
||||
const ipOf = (req: Request) => req.socket.remoteAddress ?? "unknown";
|
||||
|
||||
// Ответ подписан и несёт эхо запроса: device (X-Sen-Device) и ts (X-Sen-Ts). Клиент сверяет
|
||||
// их со своим запросом — иначе подсунуть ему можно было бы чужой подписанный ответ (чужой
|
||||
// revoked, старый config), подпись ведь на запрос не завязана.
|
||||
function send(req: Request, res: Response, status: number, payload: object) {
|
||||
const echo: { device?: number; ts?: number } = {};
|
||||
const device = Number(req.headers["x-sen-device"]), ts = Number(req.headers["x-sen-ts"]);
|
||||
if (req.headers["x-sen-device"] !== undefined && Number.isSafeInteger(device)) echo.device = device;
|
||||
if (req.headers["x-sen-ts"] !== undefined && Number.isSafeInteger(ts)) echo.ts = ts;
|
||||
// Поля ответа важнее эха: device из тела register не перетрёт лишний заголовок.
|
||||
res.status(status).json(signResponse({ ...echo, ...payload }, signKey as crypto.KeyObject));
|
||||
}
|
||||
|
||||
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
|
||||
// null — всё верно, иначе причина отказа (для лога).
|
||||
function signFailure(req: Request, authPub: Buffer): string | null {
|
||||
const ts = Number(req.headers["x-sen-ts"]);
|
||||
const sig = String(req.headers["x-sen-sig"] ?? "");
|
||||
if (!Number.isInteger(ts) || !sig) return "no_signature";
|
||||
if (Math.abs(ts - now()) > TS_WINDOW) return `ts_window (часы клиента расходятся на ${ts - now()} с)`;
|
||||
const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0);
|
||||
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return "bad_signature";
|
||||
const t = Date.now();
|
||||
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
|
||||
if (seenSigs.has(sig)) return "replay";
|
||||
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DevReq extends Request { device?: DeviceRow }
|
||||
|
||||
function deviceAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const id = Number(req.headers["x-sen-device"]);
|
||||
const dev = Number.isSafeInteger(id) && id > 0 ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
|
||||
// Устройства нет — его удалили (id не переиспользуются: AUTOINCREMENT). Отвечаем 410,
|
||||
// чтобы клиент снял конфиг, и не считаем в лимит неудач: такие клиенты опрашивают
|
||||
// по таймеру и иначе выбивали бы лимит соседям за тем же NAT. Существующему
|
||||
// устройству 410 не выдать, поэтому подписанный ответ тут можно отдать без проверки.
|
||||
if (!dev && Number.isSafeInteger(id) && id > 0) { send(req, res, 410, { error: "revoked" }); return; }
|
||||
const why = dev ? signFailure(req, Buffer.from(dev.auth_pub, "base64url")) : "no_device";
|
||||
if (!why) {
|
||||
(req as DevReq).device = dev;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает.
|
||||
const ip = ipOf(req);
|
||||
if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
|
||||
console.warn(`sub: 401 ${req.method} ${req.path} device=${req.headers["x-sen-device"] ?? "-"} ${why} ip=${ip}`);
|
||||
send(req, res, 401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
|
||||
(req: Request, res: Response) => { fn(req, res).catch(() => { send(req, res, 502, { error: "unavailable" }); }); };
|
||||
|
||||
sub.post("/sub/v1/register", wrap(async (req, res) => {
|
||||
const ip = ipOf(req);
|
||||
if (!registerLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
|
||||
|
||||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
|
||||
const secretB = str(b.sub, /^[A-Za-z0-9_-]{22}$/);
|
||||
const deviceId = str(b.device_id, /^[\w.-]{1,64}$/);
|
||||
const pubKey = str(b.pub_key, B64_PUB);
|
||||
const authPubS = str(b.auth_pub, /^[A-Za-z0-9_-]{43}$/);
|
||||
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : "";
|
||||
const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
|
||||
const version = str(b.version, CLIENT_VERSION) ?? "";
|
||||
if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, res, 400, { error: "bad_request" }); return; }
|
||||
|
||||
// Регистрация подписана ключом auth_pub из самого тела — доказательство владения.
|
||||
const authPub = Buffer.from(authPubS, "base64url");
|
||||
const why = authPub.length !== 32 ? "bad_auth_pub" : signFailure(req, authPub);
|
||||
if (why) {
|
||||
console.warn(`sub: 401 register ${why} ip=${ip}`);
|
||||
send(req, res, 401, { error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
|
||||
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
|
||||
const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id));
|
||||
if (serverIds.length === 0) { send(req, res, 502, { error: "unavailable" }); return; }
|
||||
|
||||
let deviceRowId = 0;
|
||||
let touched: number[] = [];
|
||||
try {
|
||||
uidb.transaction(() => {
|
||||
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
|
||||
if (existing) touched = forgetDevice(existing); // переустановка — старые пиры на удаление
|
||||
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
|
||||
throw Object.assign(new Error("limit"), { code: "limit" });
|
||||
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
|
||||
for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid);
|
||||
})();
|
||||
} catch (e: any) {
|
||||
if (e?.code === "limit") { send(req, res, 403, { error: "device_limit" }); return; }
|
||||
send(req, res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
|
||||
}
|
||||
|
||||
await reconcileAll([...touched, ...serverIds]);
|
||||
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
|
||||
const cfg = buildConfig(dev, req);
|
||||
if (cfg.servers.length === 0) {
|
||||
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
|
||||
await reconcileAll(forgetDevice(dev));
|
||||
send(req, res, 502, { error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
||||
// без отдельного запроса списка.
|
||||
send(req, res, 201, {
|
||||
device: dev.id, config: cfg, key_uuid: master.uuid,
|
||||
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
|
||||
});
|
||||
}));
|
||||
|
||||
// Сколько мест у ключа занято — клиент показывает это, пока ссылку только вставили и ещё ничего не
|
||||
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
|
||||
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
|
||||
sub.post("/sub/v1/peek", wrap(async (req, res) => {
|
||||
const ip = ipOf(req);
|
||||
const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null;
|
||||
const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined;
|
||||
if (!master) {
|
||||
if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
|
||||
send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
|
||||
return;
|
||||
}
|
||||
send(req, res, 200, { key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
}));
|
||||
|
||||
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
q.deviceSeen.run(now(), dev.id);
|
||||
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
|
||||
const v = String(req.headers["x-sen-version"] ?? "");
|
||||
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
|
||||
send(req, res, 200, { config: await readyConfig(dev, req) });
|
||||
}));
|
||||
|
||||
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
|
||||
// можно лишь себя (DELETE /sub/v1/device); чужие устройства убирает панель.
|
||||
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
|
||||
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
|
||||
send(req, res, 200, {
|
||||
name: master.label,
|
||||
key_uuid: master.uuid,
|
||||
device_limit: master.device_limit,
|
||||
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
|
||||
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
|
||||
created_at: d.created_at, last_seen: d.last_seen, current: d.id === dev.id,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
// Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же IP и PSK),
|
||||
// где нет — когда он появится. Если не принял ни один — ключ не меняем, клиент повторит.
|
||||
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
|
||||
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(req, res, 400, { error: "bad_request" }); return; }
|
||||
if (pub === dev.pub_key) { send(req, res, 200, { config: await readyConfig(dev, req) }); return; }
|
||||
try { q.devicePub.run(pub, 0, dev.id); }
|
||||
catch { send(req, res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
|
||||
|
||||
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
|
||||
await reconcileAll(ids);
|
||||
const cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
|
||||
if (cfg.servers.length === 0) {
|
||||
q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id);
|
||||
await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить
|
||||
send(req, res, 502, { error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
send(req, res, 200, { config: cfg });
|
||||
}));
|
||||
|
||||
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
await reconcileAll(forgetDevice(dev));
|
||||
send(req, res, 200, { ok: true });
|
||||
}));
|
||||
|
||||
sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); });
|
||||
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
||||
sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, res, 400, { error: "bad_request" }); });
|
||||
|
||||
// ── Мастер-ключи: общая логика панели и внешнего API ───────────────────
|
||||
// Панель (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys,
|
||||
// ключ awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа:
|
||||
// иначе он мог бы удалить или перенастроить раздачу на сервере, которого не видит.
|
||||
// Scope: null — панель, иначе набор серверов API-ключа.
|
||||
type Scope = number[] | null;
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
const fail = (status: number, message: string): never => { throw new ApiError(status, message); };
|
||||
|
||||
// ApiError — её статус; остальное (awg-ctrl не ответил посреди действия) — 502.
|
||||
const aw = (fn: (req: Request, res: Response) => unknown) => (req: Request, res: Response) => {
|
||||
Promise.resolve().then(() => fn(req, res)).catch(e => {
|
||||
if (e instanceof ApiError) res.status(e.status).json({ error: e.message });
|
||||
else res.status(502).json({ error: "awg-ctrl недоступен" });
|
||||
});
|
||||
};
|
||||
const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся
|
||||
// Мастер-ключ в пути — числовой id или uuid.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const masterIdOf = (v: string) => UUID_RE.test(v)
|
||||
? (q.masterIdByUuid.get(v.toLowerCase()) as { id: number } | undefined)?.id ?? -1
|
||||
: idOf(v);
|
||||
const LABEL = /^[^\r\n<>]{1,40}$/;
|
||||
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
|
||||
|
||||
const visible = (masterId: number, scope: Scope) =>
|
||||
scope === null || serverIdsOf(masterId).every(id => scope.includes(id));
|
||||
|
||||
// Живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
|
||||
function masterFor(id: number, scope: Scope): MasterRow {
|
||||
const m = q.masterById.get(id) as MasterRow | undefined;
|
||||
return m && visible(m.id, scope) ? m : fail(404, "Не найден");
|
||||
}
|
||||
|
||||
// Устройство — только вместе со своим мастер-ключом (у внешнего API пути вложенные).
|
||||
function deviceFor(id: number, master?: MasterRow): DeviceRow {
|
||||
const d = q.deviceById.get(id) as DeviceRow | undefined;
|
||||
return d && (!master || d.master_id === master.id) ? d : fail(404, "Не найдено");
|
||||
}
|
||||
|
||||
// Набор серверов из тела запроса: непустой, без повторов, только существующие и — для
|
||||
// внешнего API — только из области его ключа.
|
||||
function parseServers(v: unknown, scope: Scope): number[] {
|
||||
if (!Array.isArray(v) || v.length === 0) fail(400, "Нужен хотя бы один сервер");
|
||||
const list = v as unknown[];
|
||||
if (list.length > MAX_SERVERS) fail(400, `Не больше ${MAX_SERVERS} серверов`);
|
||||
const ids = [...new Set(list)];
|
||||
if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) fail(400, "Такого сервера нет");
|
||||
if (scope && !ids.every(id => scope.includes(id as number))) fail(403, "Сервер вне области API-ключа");
|
||||
return (ids as number[]).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
function masterOut(m: MasterRow & { devices: number }) {
|
||||
return {
|
||||
id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit,
|
||||
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
|
||||
...(m.revoked_at !== null ? { deleting: serverIdsOf(m.id) } : {}),
|
||||
};
|
||||
}
|
||||
const masterOutById = (id: number) => masterOut(q.masterRow.get(id) as MasterRow & { devices: number });
|
||||
|
||||
const listMasters = (scope: Scope) =>
|
||||
(q.masters.all() as (MasterRow & { devices: number })[]).filter(m => visible(m.id, scope)).map(masterOut);
|
||||
|
||||
// Серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область.
|
||||
function createMaster(body: unknown, scope: Scope) {
|
||||
const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown };
|
||||
if (typeof label !== "string" || !LABEL.test(label)) fail(400, "Метка: до 40 символов, без переводов строк и <>");
|
||||
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
|
||||
if (!limitOk(limit)) fail(400, "Лимит устройств: от 1 до 100");
|
||||
let ids: number[];
|
||||
try { ids = parseServers(wanted ?? scope ?? [servers.defaultId()], scope); }
|
||||
catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; }
|
||||
let id = 0;
|
||||
uidb.transaction(() => {
|
||||
id = Number(q.masterInsert.run(crypto.randomUUID(), label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
|
||||
for (const sid of ids) q.serverAdd.run(id, sid);
|
||||
})();
|
||||
return masterOutById(id);
|
||||
}
|
||||
|
||||
async function updateMaster(m: MasterRow, body: unknown, scope: Scope) {
|
||||
const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown };
|
||||
const nl = label ?? m.label, nd = device_limit ?? m.device_limit;
|
||||
if (typeof nl !== "string" || !LABEL.test(nl)) fail(400, "Неверная метка");
|
||||
if (!limitOk(nd)) fail(400, "Лимит устройств: от 1 до 100");
|
||||
|
||||
let ids = serverIdsOf(m.id);
|
||||
const affected: number[] = [];
|
||||
if (wanted !== undefined) {
|
||||
const next = parseServers(wanted, scope);
|
||||
const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s));
|
||||
const devices = q.devicesOf.all(m.id) as DeviceRow[];
|
||||
uidb.transaction(() => {
|
||||
for (const sid of removed) {
|
||||
q.serverDel.run(m.id, sid);
|
||||
for (const d of devices) {
|
||||
const l = (q.linksOf.all(d.id) as LinkRow[]).find(x => x.server_id === sid);
|
||||
if (l?.peer_pub) q.tombAdd.run(sid, l.peer_pub);
|
||||
q.linkDel.run(d.id, sid);
|
||||
}
|
||||
}
|
||||
for (const sid of added) {
|
||||
q.serverAdd.run(m.id, sid);
|
||||
for (const d of devices) q.linkAdd.run(d.id, sid);
|
||||
}
|
||||
})();
|
||||
affected.push(...added, ...removed);
|
||||
ids = next;
|
||||
}
|
||||
q.masterUpdate.run(nl as string, nd as number, m.id);
|
||||
// Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними, —
|
||||
// первые, кто занял места, их сохраняют. Как отзыв в панели: пиры снимаются, устройство получит 410.
|
||||
const unbound: number[] = [];
|
||||
const devices = q.devicesOf.all(m.id) as DeviceRow[];
|
||||
if (devices.length > (nd as number)) {
|
||||
const newest = [...devices].sort((a, b) => b.created_at - a.created_at || b.id - a.id);
|
||||
for (const d of newest.slice(0, devices.length - (nd as number))) {
|
||||
affected.push(...forgetDevice(d));
|
||||
unbound.push(d.id);
|
||||
}
|
||||
}
|
||||
await reconcileAll(affected);
|
||||
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids, unbound };
|
||||
}
|
||||
|
||||
// Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
|
||||
const rotateMaster = (m: MasterRow) => { q.masterSecret.run(crypto.randomBytes(16), m.id); };
|
||||
const rekeyMaster = (m: MasterRow) => { q.deviceFlag.run(-1, m.id); };
|
||||
|
||||
// Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся
|
||||
// отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель
|
||||
// сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются.
|
||||
async function deleteMaster(m: MasterRow) {
|
||||
const affected = serverIdsOf(m.id);
|
||||
uidb.transaction(() => {
|
||||
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
|
||||
q.masterRevoke.run(now(), m.id);
|
||||
})();
|
||||
await reconcileAll(affected);
|
||||
return { success: true, id: m.id, pending: serverIdsOf(m.id) };
|
||||
}
|
||||
|
||||
// Выдать ссылку можно, только если подписка настроена и известен адрес панели.
|
||||
function linkProblem(req: Request): string | null {
|
||||
if (!signKey || !signPub || !SUB_PORT) return "Подписка не настроена на сервере";
|
||||
if (!subHost(req)) return "Не удалось определить адрес панели — задайте SUB_HOST";
|
||||
return null;
|
||||
}
|
||||
|
||||
function linkOf(m: MasterRow, req: Request) {
|
||||
const problem = linkProblem(req);
|
||||
if (problem) fail(503, problem);
|
||||
const link = encodeSenLink({
|
||||
tls: tlsPin !== null,
|
||||
addrs: [{ host: subHost(req), port: SUB_PORT }],
|
||||
secret: m.secret, signPub: signPub as Buffer, tlsPin: tlsPin ?? undefined, name: m.label,
|
||||
});
|
||||
return { link, tls: tlsPin !== null };
|
||||
}
|
||||
|
||||
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
|
||||
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
|
||||
async function masterDevices(m: MasterRow) {
|
||||
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
|
||||
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
|
||||
const r = await servers.ctrlFor(id)("GET", "/api/peers");
|
||||
if (!hasCtrlOk(r.status)) return;
|
||||
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) {
|
||||
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 };
|
||||
stats.set(p.pub_key, {
|
||||
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
|
||||
rx: s.rx + p.rx, tx: s.tx + p.tx,
|
||||
});
|
||||
}
|
||||
}));
|
||||
return (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
|
||||
const links = q.linksOf.all(d.id) as LinkRow[];
|
||||
const s = stats.get(d.pub_key);
|
||||
return {
|
||||
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
|
||||
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
|
||||
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
|
||||
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteDevice(d: DeviceRow) {
|
||||
await reconcileAll(forgetDevice(d));
|
||||
return { success: true, id: d.id };
|
||||
}
|
||||
const rekeyDevice = (d: DeviceRow) => { q.deviceFlag.run(d.id, -1); };
|
||||
|
||||
// Новый PSK на каждом сервере; недоступные получат его, когда появятся.
|
||||
async function rotatePsk(d: DeviceRow) {
|
||||
q.linkPskDue.run(d.id);
|
||||
await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id));
|
||||
return { id: d.id, pending: (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length };
|
||||
}
|
||||
|
||||
// ── Роуты панели (монтируются в server.ts за JWT) ──────────────────────
|
||||
const masterKeys = Router();
|
||||
masterKeys.get("/", (_req, res) => {
|
||||
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
|
||||
});
|
||||
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
|
||||
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
|
||||
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(masterIdOf(req.params.id), null))); }));
|
||||
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(masterIdOf(req.params.id), null), req)); }));
|
||||
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(masterIdOf(req.params.id), null)) }); }));
|
||||
|
||||
const devices = Router();
|
||||
devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); }));
|
||||
devices.post("/:id/rekey", aw((req, res) => { const d = deviceFor(idOf(req.params.id)); rekeyDevice(d); res.json({ id: d.id }); }));
|
||||
devices.post("/:id/psk", aw(async (req, res) => { res.json(await rotatePsk(deviceFor(idOf(req.params.id)))); }));
|
||||
|
||||
// ── Внешний API: /api/v1/masterkeys (ключ awgk_, монтируется в server.ts) ─
|
||||
// scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы
|
||||
// область проверялась через их мастер-ключ.
|
||||
function apiRouter(scopeOf: (req: Request) => number[]) {
|
||||
const r = Router();
|
||||
const master = (req: Request) => masterFor(masterIdOf(req.params.id), scopeOf(req));
|
||||
const device = (req: Request) => deviceFor(idOf(req.params.device), master(req));
|
||||
|
||||
r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); }));
|
||||
r.post("/", aw((req, res) => {
|
||||
const problem = linkProblem(req); // без ссылки ключ внешней программе бесполезен
|
||||
if (problem) fail(503, problem);
|
||||
const out = createMaster(req.body, scopeOf(req));
|
||||
res.status(201).json({ ...out, ...linkOf(masterFor(out.id, null), req) });
|
||||
}));
|
||||
r.get("/:id", aw(async (req, res) => {
|
||||
const m = master(req);
|
||||
// devices — счётчик, как в списке; сами устройства — в device_list.
|
||||
res.json({ ...masterOutById(m.id), ...(linkProblem(req) ? { link: null, tls: tlsPin !== null } : linkOf(m, req)), device_list: await masterDevices(m) });
|
||||
}));
|
||||
r.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(master(req), req.body, scopeOf(req))); }));
|
||||
r.post("/:id/rotate", aw((req, res) => {
|
||||
const m = master(req);
|
||||
rotateMaster(m);
|
||||
res.json({ id: m.id, ...linkOf(masterFor(m.id, null), req) });
|
||||
}));
|
||||
r.post("/:id/rekey", aw((req, res) => { const m = master(req); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||
r.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(master(req))); }));
|
||||
r.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(master(req)) }); }));
|
||||
r.delete("/:id/devices/:device", aw(async (req, res) => { res.json(await deleteDevice(device(req))); }));
|
||||
r.post("/:id/devices/:device/rekey", aw((req, res) => { const d = device(req); rekeyDevice(d); res.json({ id: d.id }); }));
|
||||
r.post("/:id/devices/:device/psk", aw(async (req, res) => { res.json(await rotatePsk(device(req))); }));
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── Листенер и фоновое сведение ────────────────────────────────────────
|
||||
servers.onOnline(id => { void reconcile(id); });
|
||||
|
||||
// Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва
|
||||
// сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после
|
||||
// удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force;
|
||||
// тогда отозванные ключи перестают его ждать уже после удаления (afterDelete).
|
||||
servers.beforeDelete(async (id, force) => {
|
||||
const live = (q.liveOnServer.all(id) as { label: string }[]).map(r => r.label);
|
||||
if (live.length) return { error: `Сервер в мастер-ключах: ${live.join(", ")} — сначала уберите его оттуда`, forcible: false };
|
||||
if (servers.isOnline(id)) await reconcile(id);
|
||||
const waiting = (q.revokedOnServer.all(id) as { label: string }[]).map(r => r.label);
|
||||
const tombs = (q.tombCount.get(id) as { n: number }).n;
|
||||
if (!waiting.length && !tombs || force) return null;
|
||||
return {
|
||||
error: "Сервер не подтвердил, что снял пиров" + (waiting.length ? ` удалённых мастер-ключей (${waiting.join(", ")})` : "") +
|
||||
". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)",
|
||||
forcible: true,
|
||||
};
|
||||
});
|
||||
servers.afterDelete(id => {
|
||||
q.serverRelease.run(id);
|
||||
q.revokedGone.run();
|
||||
});
|
||||
|
||||
function start() {
|
||||
// Сводим и без подписки: у ключей могут остаться хвосты на удаление.
|
||||
setInterval(() => {
|
||||
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
|
||||
}, SYNC_EVERY).unref();
|
||||
|
||||
if (!signKey) return;
|
||||
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
|
||||
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
|
||||
server.listen(SUB_PORT, () => {
|
||||
console.log(`sub: listening on :${SUB_PORT} (${tlsMaterial ? "https" : "http"})`);
|
||||
});
|
||||
}
|
||||
|
||||
return { masterKeys, devices, apiRouter, start };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/ui': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
'/login': 'http://localhost:8080',
|
||||
'/logout': 'http://localhost:8080',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.1.4",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+319
-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";
|
||||
@@ -15,19 +15,31 @@ 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 +83,74 @@ 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 ?? "",
|
||||
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 +263,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 +292,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 +313,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 +324,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 +346,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 +585,7 @@ async function interactiveMenu() {
|
||||
|
||||
const printMenu = () => {
|
||||
console.clear();
|
||||
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4 ──${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 +619,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 +640,23 @@ 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 <область>]>";
|
||||
|
||||
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;
|
||||
default: console.log(USAGE); process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+907
-172
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user