Compare commits
17
Commits
v0.1.3.1
...
a358504e2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a358504e2a | ||
|
|
66ae59e1d0 | ||
|
|
a11cfc88de | ||
|
|
44c032b376 | ||
|
|
068cab4d24 | ||
|
|
21c7409017 | ||
|
|
ca1c8e8c68 | ||
|
|
602c0416ba | ||
|
|
18972ed58f | ||
|
|
38e76da84f | ||
|
|
0dbb13f120 | ||
|
|
b097ccd5fb | ||
|
|
c0e19f284d | ||
|
|
6fdacd71c3 | ||
|
|
af5c0e1b43 | ||
|
|
01d8524f8c | ||
|
|
44c637f7af |
@@ -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.2.1
|
||||||
@@ -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 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: |
|
run: |
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
mkdir "awgcontrol-${VERSION}"
|
mkdir "awgcontrol-${VERSION}"
|
||||||
cp -r awg-ctrl awg-ui cli "awgcontrol-${VERSION}/"
|
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
|
||||||
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
@@ -37,4 +37,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
|
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
|
||||||
--title "${GITHUB_REF_NAME}" --generate-notes
|
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| Компонент | Роль |
|
| Компонент | Роль |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
|
| **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-меню. |
|
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
|
||||||
|
|
||||||
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
|
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
|
||||||
@@ -28,8 +28,134 @@
|
|||||||
На сервере, от root:
|
На сервере, от root:
|
||||||
|
|
||||||
```bash
|
```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: у пира нет изменяемых полей, «перевыпустить»
|
||||||
|
ключ = удалить и создать заново.
|
||||||
|
|
||||||
|
| Код | Когда |
|
||||||
|
|---|---|
|
||||||
|
| `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` и
|
||||||
|
`DELETE /ui/apikeys/:id`.
|
||||||
|
|
||||||
|
### Авторизация панели
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user":"admin","pass":"***"}' | jq -r .token)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Метод | Ответ и ошибки |
|
||||||
|
|---|---|
|
||||||
|
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
|
||||||
|
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
|
||||||
|
|
||||||
|
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
|
||||||
|
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
|
||||||
|
|
||||||
|
### Состояние
|
||||||
|
|
||||||
|
| Метод | Ответ |
|
||||||
|
|---|---|
|
||||||
|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.2.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||||
|
| `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.
|
||||||
|
|
||||||
## Управление
|
## Управление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -42,103 +168,5 @@ awg-ctrl
|
|||||||
systemctl start|stop|restart awg-control
|
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
|
[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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
+411
-35
@@ -3,6 +3,7 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { execSync, spawnSync } from "child_process";
|
import { execSync, spawnSync } from "child_process";
|
||||||
import * as zlib from "zlib";
|
import * as zlib from "zlib";
|
||||||
@@ -64,6 +65,21 @@ db.exec(`
|
|||||||
|
|
||||||
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)");
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)");
|
||||||
|
|
||||||
|
// Миграции users: better-sqlite3 синхронный, ALTER TABLE идемпотентным не бывает,
|
||||||
|
// поэтому смотрим фактический список колонок.
|
||||||
|
// key_gen — поколение протокола, на параметрах которого выдан vpn_key
|
||||||
|
// ('2' для всего, что заведено до появления 3.1)
|
||||||
|
// vpn_key_prev — предыдущий блоб, чтобы перевыпуск можно было откатить
|
||||||
|
{
|
||||||
|
const cols = new Set(
|
||||||
|
(db.prepare("PRAGMA table_info(users)").all() as { name: string }[]).map(c => c.name),
|
||||||
|
);
|
||||||
|
if (!cols.has("key_gen"))
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'");
|
||||||
|
if (!cols.has("vpn_key_prev"))
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -106,12 +122,29 @@ function initConfig() {
|
|||||||
return { serverIp, serverPort: Number(serverPort), serverName };
|
return { serverIp, serverPort: Number(serverPort), serverName };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AwgParams {
|
// Ключи, появившиеся в AmneziaWG 3.0/3.1. Пустая строка = ключ не задан, тогда
|
||||||
|
// интерфейс работает в режиме 2.0 и его нет ни в conf, ни в vpn:// ключе.
|
||||||
|
// Порядок массива = порядок строк в клиентском .conf (см. buildClientConf).
|
||||||
|
const AWG3_KEYS = [
|
||||||
|
"HeaderProtectionKey",
|
||||||
|
"ContentPaddingAddition",
|
||||||
|
"RekeyAfterTime",
|
||||||
|
"RekeyTimeout",
|
||||||
|
"RejectAfterTime",
|
||||||
|
"KeepaliveTimeout",
|
||||||
|
"MaxHandshakeAttempts",
|
||||||
|
"RandomTrailers",
|
||||||
|
"DisableCookies",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Awg3Key = (typeof AWG3_KEYS)[number];
|
||||||
|
|
||||||
|
type AwgParams = {
|
||||||
Jc: number; Jmin: number; Jmax: number;
|
Jc: number; Jmin: number; Jmax: number;
|
||||||
S1: number; S2: number; S3: number; S4: number;
|
S1: number; S2: number; S3: number; S4: number;
|
||||||
H1: string; H2: string; H3: string; H4: string;
|
H1: string; H2: string; H3: string; H4: string;
|
||||||
I1: string; I2: string; I3: string; I4: string; I5: string;
|
I1: string; I2: string; I3: string; I4: string; I5: string;
|
||||||
}
|
} & Record<Awg3Key, string>;
|
||||||
|
|
||||||
const DEFAULT_AWG_PARAMS: AwgParams = {
|
const DEFAULT_AWG_PARAMS: AwgParams = {
|
||||||
Jc: 6, Jmin: 10, Jmax: 50,
|
Jc: 6, Jmin: 10, Jmax: 50,
|
||||||
@@ -122,6 +155,11 @@ const DEFAULT_AWG_PARAMS: AwgParams = {
|
|||||||
H4: "2143656228-2147444225",
|
H4: "2143656228-2147444225",
|
||||||
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
|
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
|
||||||
I2: "", I3: "", I4: "", I5: "",
|
I2: "", I3: "", I4: "", I5: "",
|
||||||
|
// 3.x по умолчанию выключен: без conf-а сервер остаётся на 2.0.
|
||||||
|
HeaderProtectionKey: "", ContentPaddingAddition: "",
|
||||||
|
RekeyAfterTime: "", RekeyTimeout: "", RejectAfterTime: "",
|
||||||
|
KeepaliveTimeout: "", MaxHandshakeAttempts: "",
|
||||||
|
RandomTrailers: "", DisableCookies: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function readAwgParams(): AwgParams {
|
function readAwgParams(): AwgParams {
|
||||||
@@ -134,7 +172,9 @@ function readAwgParams(): AwgParams {
|
|||||||
const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0];
|
const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0];
|
||||||
|
|
||||||
const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"];
|
const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"];
|
||||||
const strKeys: (keyof AwgParams)[] = ["H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"];
|
const strKeys: (keyof AwgParams)[] = [
|
||||||
|
"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5", ...AWG3_KEYS,
|
||||||
|
];
|
||||||
|
|
||||||
for (const k of numKeys) {
|
for (const k of numKeys) {
|
||||||
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m"));
|
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m"));
|
||||||
@@ -144,11 +184,69 @@ function readAwgParams(): AwgParams {
|
|||||||
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"));
|
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"));
|
||||||
if (m) (params[k] as string) = m[1].trim();
|
if (m) (params[k] as string) = m[1].trim();
|
||||||
}
|
}
|
||||||
logger.info("awg params loaded from conf", { Jc: params.Jc, H1: params.H1 });
|
logger.info("awg params loaded from conf", {
|
||||||
|
Jc: params.Jc, H1: params.H1, gen: genOf(params),
|
||||||
|
});
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поколение протокола выводим из самих параметров, отдельного флага нет: conf
|
||||||
|
// остаётся единственным источником правды. Header protection — та фича, которая
|
||||||
|
// ломает совместимость с 2.0, поэтому именно она и определяет поколение.
|
||||||
|
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 runtimeConfig = initConfig();
|
||||||
|
const AWG_PARAMS = readAwgParams();
|
||||||
|
const AWG_GEN = genOf(AWG_PARAMS);
|
||||||
|
const AWG_VERSIONS = readAwgVersions();
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
interface: "awg1",
|
interface: "awg1",
|
||||||
confDir: "/etc/amnezia/amneziawg",
|
confDir: "/etc/amnezia/amneziawg",
|
||||||
@@ -159,23 +257,36 @@ const CONFIG = {
|
|||||||
dns1: "1.1.1.1",
|
dns1: "1.1.1.1",
|
||||||
dns2: "1.0.0.1",
|
dns2: "1.0.0.1",
|
||||||
mtu: 1376,
|
mtu: 1376,
|
||||||
keepalive: 25,
|
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
|
||||||
awgParams: readAwgParams(),
|
awgParams: AWG_PARAMS,
|
||||||
|
gen: AWG_GEN,
|
||||||
|
// Мутируются в restartAwg() — единственном месте, где модуль может
|
||||||
|
// реально смениться в течение жизни процесса.
|
||||||
|
awgModule: AWG_VERSIONS.module,
|
||||||
|
awgTools: AWG_VERSIONS.tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
pub_key: string;
|
pub_key: string;
|
||||||
vpn_key: string;
|
vpn_key: string;
|
||||||
psk_key: string;
|
psk_key: string;
|
||||||
|
key_gen: string;
|
||||||
|
vpn_key_prev: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stmts = {
|
const stmts = {
|
||||||
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
|
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
|
||||||
insert: db.prepare<[string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key) VALUES (?, ?, ?, ?, ?)"),
|
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 = ?"),
|
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"),
|
||||||
|
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
|
||||||
|
// смениться, если исходный ключ не удалось разобрать.
|
||||||
|
reissue: db.prepare<[string, string, string, string, string]>(
|
||||||
|
"UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
function run(cmd: string): string {
|
function run(cmd: string): string {
|
||||||
@@ -207,7 +318,10 @@ function nextIp(): string {
|
|||||||
throw new Error("Подсеть заполнена");
|
throw new Error("Подсеть заполнена");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → Address → DNS
|
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → 3.x-ключи →
|
||||||
|
// Address → DNS. Порядок 3.x-блока взят из client/server_scripts/awg/template.conf
|
||||||
|
// клиента AmneziaVPN; пустые ключи не выводятся вовсе — тогда конфиг остаётся
|
||||||
|
// ровно тем же 2.0-конфигом, что и до появления поддержки 3.1.
|
||||||
// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом
|
// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом
|
||||||
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
|
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
|
||||||
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
|
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
|
||||||
@@ -217,6 +331,7 @@ function buildClientConf(
|
|||||||
serverPub: string,
|
serverPub: string,
|
||||||
): string {
|
): string {
|
||||||
const p = CONFIG.awgParams;
|
const p = CONFIG.awgParams;
|
||||||
|
const awg3 = AWG3_KEYS.filter(k => p[k]).map(k => `${k} = ${p[k]}\n`).join("");
|
||||||
return `[Interface]
|
return `[Interface]
|
||||||
PrivateKey = ${keys.privateKey}
|
PrivateKey = ${keys.privateKey}
|
||||||
Jc = ${p.Jc}
|
Jc = ${p.Jc}
|
||||||
@@ -235,7 +350,7 @@ I2 =${" "}
|
|||||||
I3 =${" "}
|
I3 =${" "}
|
||||||
I4 =${" "}
|
I4 =${" "}
|
||||||
I5 =${" "}
|
I5 =${" "}
|
||||||
Address = ${ip}/32
|
${awg3}Address = ${ip}/32
|
||||||
DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
|
DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
@@ -255,12 +370,29 @@ function encodeVpnKey(
|
|||||||
const p = CONFIG.awgParams;
|
const p = CONFIG.awgParams;
|
||||||
const clientConf = buildClientConf(keys, ip, serverPub);
|
const clientConf = buildClientConf(keys, ip, serverPub);
|
||||||
|
|
||||||
const lastConfigObj = {
|
// Ключи в объектах идут в том же ASCII-алфавитном порядке, в каком их
|
||||||
|
// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи
|
||||||
|
// выбрасываются в dropEmptyAwg3 — на 2.0 объекты остаются прежними байт-в-байт.
|
||||||
|
const dropEmptyAwg3 = <T extends Record<string, unknown>>(o: T): T => {
|
||||||
|
for (const k of AWG3_KEYS) if (!p[k]) delete o[k];
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastConfigObj = dropEmptyAwg3({
|
||||||
|
ContentPaddingAddition: p.ContentPaddingAddition,
|
||||||
|
DisableCookies: p.DisableCookies,
|
||||||
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
|
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
|
||||||
|
HeaderProtectionKey: p.HeaderProtectionKey,
|
||||||
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
|
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
|
||||||
Jc: String(p.Jc),
|
Jc: String(p.Jc),
|
||||||
Jmax: String(p.Jmax),
|
Jmax: String(p.Jmax),
|
||||||
Jmin: String(p.Jmin),
|
Jmin: String(p.Jmin),
|
||||||
|
KeepaliveTimeout: p.KeepaliveTimeout,
|
||||||
|
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
|
||||||
|
RandomTrailers: p.RandomTrailers,
|
||||||
|
RejectAfterTime: p.RejectAfterTime,
|
||||||
|
RekeyAfterTime: p.RekeyAfterTime,
|
||||||
|
RekeyTimeout: p.RekeyTimeout,
|
||||||
S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4),
|
S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4),
|
||||||
allowed_ips: ["0.0.0.0/0", "::/0"],
|
allowed_ips: ["0.0.0.0/0", "::/0"],
|
||||||
clientId: keys.publicKey,
|
clientId: keys.publicKey,
|
||||||
@@ -274,25 +406,34 @@ function encodeVpnKey(
|
|||||||
port: CONFIG.serverPort,
|
port: CONFIG.serverPort,
|
||||||
psk_key: keys.presharedKey,
|
psk_key: keys.presharedKey,
|
||||||
server_pub_key: serverPub,
|
server_pub_key: serverPub,
|
||||||
};
|
});
|
||||||
|
|
||||||
const json = JSON.stringify({
|
const json = JSON.stringify({
|
||||||
containers: [{
|
containers: [{
|
||||||
container: "amnezia-awg2",
|
container: "amnezia-awg2",
|
||||||
awg: {
|
awg: dropEmptyAwg3({
|
||||||
|
ContentPaddingAddition: p.ContentPaddingAddition,
|
||||||
|
DisableCookies: p.DisableCookies,
|
||||||
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
|
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
|
||||||
|
HeaderProtectionKey: p.HeaderProtectionKey,
|
||||||
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
|
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
|
||||||
Jc: String(p.Jc),
|
Jc: String(p.Jc),
|
||||||
Jmax: String(p.Jmax),
|
Jmax: String(p.Jmax),
|
||||||
Jmin: String(p.Jmin),
|
Jmin: String(p.Jmin),
|
||||||
|
KeepaliveTimeout: p.KeepaliveTimeout,
|
||||||
|
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
|
||||||
|
RandomTrailers: p.RandomTrailers,
|
||||||
|
RejectAfterTime: p.RejectAfterTime,
|
||||||
|
RekeyAfterTime: p.RekeyAfterTime,
|
||||||
|
RekeyTimeout: p.RekeyTimeout,
|
||||||
S1: String(p.S1), S2: String(p.S2),
|
S1: String(p.S1), S2: String(p.S2),
|
||||||
S3: String(p.S3), S4: String(p.S4),
|
S3: String(p.S3), S4: String(p.S4),
|
||||||
last_config: JSON.stringify(lastConfigObj, null, 2),
|
last_config: JSON.stringify(lastConfigObj, null, 2),
|
||||||
port: String(CONFIG.serverPort),
|
port: String(CONFIG.serverPort),
|
||||||
protocol_version: "2",
|
protocol_version: CONFIG.gen,
|
||||||
subnet_address: `${CONFIG.subnet}.0.0`,
|
subnet_address: `${CONFIG.subnet}.0.0`,
|
||||||
transport_proto: "udp",
|
transport_proto: "udp",
|
||||||
},
|
}),
|
||||||
}],
|
}],
|
||||||
defaultContainer: "amnezia-awg2",
|
defaultContainer: "amnezia-awg2",
|
||||||
description: CONFIG.serverName,
|
description: CONFIG.serverName,
|
||||||
@@ -311,6 +452,77 @@ function encodeVpnKey(
|
|||||||
.replace(/=+$/, "");
|
.replace(/=+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обратная к encodeVpnKey: vpn:// → base64url → снять 4-байтовый BE-заголовок
|
||||||
|
// длины → inflate → JSON. Приватный ключ клиента больше нигде не хранится, поэтому
|
||||||
|
// это единственный способ перевыпустить ключ, не меняя личность пира.
|
||||||
|
function decodeVpnKey(vpnKey: string): any | null {
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(vpnKey.replace(/^vpn:\/\//, ""), "base64url");
|
||||||
|
if (buf.length <= 4) return null;
|
||||||
|
return JSON.parse(zlib.inflateSync(buf.subarray(4)).toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientPrivKeyFrom(vpnKey: string): string | null {
|
||||||
|
const lastConfig = decodeVpnKey(vpnKey)?.containers?.[0]?.awg?.last_config;
|
||||||
|
if (typeof lastConfig !== "string") return null;
|
||||||
|
try {
|
||||||
|
const priv = JSON.parse(lastConfig).client_priv_key;
|
||||||
|
return typeof priv === "string" && priv ? priv : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReissueResult { total: number; reissued: number; regenerated: string[]; backup: string }
|
||||||
|
|
||||||
|
// Перевыпуск всех vpn:// ключей на текущих параметрах интерфейса. Нужен после
|
||||||
|
// смены поколения (2.0 → 3.1): старые ключи собраны на старых параметрах и
|
||||||
|
// перестают работать. IP, pub_key и psk_key сохраняются — на проводе ничего не
|
||||||
|
// меняется, клиенту достаточно заново импортировать ключ.
|
||||||
|
function reissueAll(): ReissueResult {
|
||||||
|
const users = stmts.all.all();
|
||||||
|
const serverPub = getServerPublicKey();
|
||||||
|
const backup = `${dbPath}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||||
|
db.prepare("VACUUM INTO ?").run(backup);
|
||||||
|
logger.info("reissue: db backed up", { backup, users: users.length });
|
||||||
|
|
||||||
|
const regenerated: string[] = [];
|
||||||
|
let reissued = 0;
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const priv = clientPrivKeyFrom(u.vpn_key);
|
||||||
|
let keys: ReturnType<typeof generateKeys>;
|
||||||
|
|
||||||
|
if (priv) {
|
||||||
|
keys = { privateKey: priv, publicKey: u.pub_key, presharedKey: u.psk_key };
|
||||||
|
} else {
|
||||||
|
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
|
||||||
|
logger.warn("reissue: vpn_key не декодируется, генерируем новую пару", { name: u.name });
|
||||||
|
keys = generateKeys();
|
||||||
|
spawnSync("awg", ["set", CONFIG.interface, "peer", u.pub_key, "remove"]);
|
||||||
|
const r = setPeer(keys.publicKey, keys.presharedKey, u.ip);
|
||||||
|
if (r.status !== 0) {
|
||||||
|
logger.error("reissue: awg set failed", { name: u.name, stderr: r.stderr?.toString() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
regenerated.push(u.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
stmts.reissue.run(
|
||||||
|
encodeVpnKey(keys, u.ip, serverPub),
|
||||||
|
keys.publicKey, keys.presharedKey, CONFIG.gen, u.name,
|
||||||
|
);
|
||||||
|
reissued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPeers();
|
||||||
|
logger.info("reissue: done", { total: users.length, reissued, regenerated: regenerated.length });
|
||||||
|
return { total: users.length, reissued, regenerated, backup };
|
||||||
|
}
|
||||||
|
|
||||||
function getPeersData(): Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> {
|
function getPeersData(): Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> {
|
||||||
try {
|
try {
|
||||||
const output = run(`awg show ${CONFIG.interface} dump`);
|
const output = run(`awg show ${CONFIG.interface} dump`);
|
||||||
@@ -410,6 +622,17 @@ function ensureInterfaceUp() {
|
|||||||
if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`);
|
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() {
|
function restartAwg() {
|
||||||
logger.info("AWG restart: down");
|
logger.info("AWG restart: down");
|
||||||
const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
|
const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
|
||||||
@@ -417,8 +640,126 @@ function restartAwg() {
|
|||||||
logger.info("AWG restart: up");
|
logger.info("AWG restart: up");
|
||||||
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
||||||
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
|
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();
|
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() {
|
function startInterface() {
|
||||||
@@ -430,36 +771,47 @@ function startInterface() {
|
|||||||
return getInterfaceStatus();
|
return getInterfaceStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PSK уходит во временный файл, а не в аргументы: иначе он виден в /proc/<pid>/cmdline.
|
||||||
|
function setPeer(pubKey: string, psk: string, ip: string) {
|
||||||
|
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
|
||||||
|
writeFileSync(tmpPsk, psk, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
return spawnSync("awg", [
|
||||||
|
"set", CONFIG.interface, "peer", pubKey,
|
||||||
|
"preshared-key", tmpPsk,
|
||||||
|
"allowed-ips", `${ip}/32`,
|
||||||
|
"persistent-keepalive", CONFIG.keepalive,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpPsk); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addUser(username: string): UserRow {
|
function addUser(username: string): UserRow {
|
||||||
const keys = generateKeys();
|
const keys = generateKeys();
|
||||||
const serverPub = getServerPublicKey();
|
const serverPub = getServerPublicKey();
|
||||||
|
|
||||||
const ip = db.transaction(() => {
|
const ip = db.transaction(() => {
|
||||||
const ip = nextIp();
|
const ip = nextIp();
|
||||||
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey);
|
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey, CONFIG.gen);
|
||||||
return ip;
|
return ip;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const vpn_key = encodeVpnKey(keys, ip, serverPub);
|
const vpn_key = encodeVpnKey(keys, ip, serverPub);
|
||||||
db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username);
|
db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username);
|
||||||
|
|
||||||
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
|
const r = setPeer(keys.publicKey, keys.presharedKey, ip);
|
||||||
writeFileSync(tmpPsk, keys.presharedKey);
|
|
||||||
const r = spawnSync("awg", [
|
|
||||||
"set", CONFIG.interface, "peer", keys.publicKey,
|
|
||||||
"preshared-key", tmpPsk,
|
|
||||||
"allowed-ips", `${ip}/32`,
|
|
||||||
"persistent-keepalive", String(CONFIG.keepalive),
|
|
||||||
]);
|
|
||||||
try { fs.unlinkSync(tmpPsk); } catch {}
|
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
stmts.delete.run(username);
|
stmts.delete.run(username);
|
||||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildConf();
|
rebuildConf();
|
||||||
logger.info("user created", { name: username, ip });
|
logger.info("user created", { name: username, ip, gen: CONFIG.gen });
|
||||||
return { name: username, ip, pub_key: keys.publicKey, vpn_key, psk_key: keys.presharedKey };
|
return {
|
||||||
|
name: username, ip, pub_key: keys.publicKey, vpn_key,
|
||||||
|
psk_key: keys.presharedKey, key_gen: CONFIG.gen, vpn_key_prev: "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeUser(username: string) {
|
function removeUser(username: string) {
|
||||||
@@ -521,7 +873,8 @@ app.get("/health", (_req, res) => {
|
|||||||
status: up ? "ok" : "degraded",
|
status: up ? "ok" : "degraded",
|
||||||
server: CONFIG.serverName,
|
server: CONFIG.serverName,
|
||||||
ip: CONFIG.serverIp,
|
ip: CONFIG.serverIp,
|
||||||
awg: { status: up ? "ok" : "down", peers },
|
gen: CONFIG.gen,
|
||||||
|
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,7 +887,7 @@ app.post("/api/users", auth, validateName, handler((req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/users", auth, handler((_req, res) => {
|
app.get("/api/users", auth, handler((_req, res) => {
|
||||||
const users = db.prepare("SELECT name, ip, pub_key, vpn_key FROM users WHERE vpn_key != ''").all() as UserRow[];
|
const users = db.prepare("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
|
||||||
const peers = getPeersData();
|
const peers = getPeersData();
|
||||||
res.json({
|
res.json({
|
||||||
users: users.map(u => ({
|
users: users.map(u => ({
|
||||||
@@ -546,12 +899,13 @@ app.get("/api/users", auth, handler((_req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/users/stats", auth, handler((_req, res) => {
|
app.get("/api/users/stats", auth, handler((_req, res) => {
|
||||||
const users = db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[];
|
const users = db.prepare("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
|
||||||
const peers = getPeersData();
|
const peers = getPeersData();
|
||||||
res.json({
|
res.json({
|
||||||
users: users.map(u => ({
|
users: users.map(u => ({
|
||||||
name: u.name,
|
name: u.name,
|
||||||
ip: u.ip,
|
ip: u.ip,
|
||||||
|
key_gen: u.key_gen,
|
||||||
online: peers[u.pub_key]?.online ?? false,
|
online: peers[u.pub_key]?.online ?? false,
|
||||||
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
|
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
|
||||||
rx: peers[u.pub_key]?.rx ?? 0,
|
rx: peers[u.pub_key]?.rx ?? 0,
|
||||||
@@ -560,6 +914,12 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ⚠️ Должен быть объявлен ДО «/api/users/:name», иначе тот перехватит «reissue»
|
||||||
|
// как имя пользователя.
|
||||||
|
app.post("/api/users/reissue", auth, handler((_req, res) => {
|
||||||
|
res.json(reissueAll());
|
||||||
|
}));
|
||||||
|
|
||||||
app.post("/api/users/:name", auth, validateName, handler((req, res) => {
|
app.post("/api/users/:name", auth, validateName, handler((req, res) => {
|
||||||
const user = stmts.get.get(req.params.name);
|
const user = stmts.get.get(req.params.name);
|
||||||
if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; }
|
if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; }
|
||||||
@@ -576,6 +936,22 @@ app.post("/awg/restart", auth, handler((_req, res) => {
|
|||||||
res.json({ success: true });
|
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) => {
|
app.get("/awg/status", auth, handler((_req, res) => {
|
||||||
res.json(getInterfaceStatus());
|
res.json(getInterfaceStatus());
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "1.0.0",
|
"version": "0.2.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
""
|
"index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.1.3+1",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+51
-12
@@ -16,6 +16,20 @@ const UI_PASS = process.env.UI_PASS ?? "";
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
|
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
|
||||||
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
|
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
|
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE
|
||||||
?? path.join(__dirname, "internal_auth_private.key");
|
?? path.join(__dirname, "internal_auth_private.key");
|
||||||
let internalAuthKey: crypto.KeyObject | null = null;
|
let internalAuthKey: crypto.KeyObject | null = null;
|
||||||
@@ -37,6 +51,9 @@ function mintInternalToken(): string {
|
|||||||
|
|
||||||
const revoked = new Set<string>();
|
const revoked = new Set<string>();
|
||||||
|
|
||||||
|
// ── Ключи внешнего API (/api/v1) ───────────────────────────────────────────
|
||||||
|
// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое
|
||||||
|
// значение ключа не хранится — только SHA-256 и префикс для показа в панели.
|
||||||
interface ApiKeyRow {
|
interface ApiKeyRow {
|
||||||
id: number; label: string; key_hash: string; prefix: string;
|
id: number; label: string; key_hash: string; prefix: string;
|
||||||
server_id: number; created_at: number; last_used: number | null;
|
server_id: number; created_at: number; last_used: number | null;
|
||||||
@@ -146,6 +163,13 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
|
||||||
|
// до выдачи 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) => {
|
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
|
||||||
res.json({ keys: keyStmts.list.all() });
|
res.json({ keys: keyStmts.list.all() });
|
||||||
});
|
});
|
||||||
@@ -159,6 +183,7 @@ app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
|
|||||||
// список серверов — валидировать его против реального реестра серверов.
|
// список серверов — валидировать его против реального реестра серверов.
|
||||||
const { key, hash, prefix } = genApiKey();
|
const { key, hash, prefix } = genApiKey();
|
||||||
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
|
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 });
|
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,34 +235,48 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Внешний контракт: наружу отдаём только name/ip/gen/vpn_key — psk_key и pub_key
|
||||||
|
// остаются внутри. gen — поколение AmneziaWG, на параметрах которого выдан ключ.
|
||||||
|
interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
|
||||||
|
|
||||||
const ext = express.Router();
|
const ext = express.Router();
|
||||||
ext.use(requireApiKey);
|
ext.use(requireApiKey);
|
||||||
|
|
||||||
ext.post("/users", async (req: Request, res: Response) => {
|
// 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 { name } = (req.body ?? {}) as { name?: string };
|
||||||
const r = await ctrl("POST", "/api/users", { name });
|
const r = await ctrl("POST", "/api/users", { name });
|
||||||
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||||
const u = r.data as { name: string; ip: string; vpn_key: string };
|
const u = r.data as ExtUser;
|
||||||
res.status(201).json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
|
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) => {
|
ext.get("/users", wrap(async (_req, res) => {
|
||||||
const r = await ctrl("GET", "/api/users/stats");
|
const r = await ctrl("GET", "/api/users/stats");
|
||||||
res.status(r.status).json(r.data);
|
res.status(r.status).json(r.data);
|
||||||
});
|
}));
|
||||||
|
|
||||||
ext.get("/users/:name", async (req: Request, res: Response) => {
|
ext.get("/users/:name", wrap(async (req, res) => {
|
||||||
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
|
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||||
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||||
const u = r.data as { name: string; ip: string; vpn_key: string };
|
const u = r.data as ExtUser;
|
||||||
res.json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
|
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) => {
|
ext.delete("/users/:name", wrap(async (req, res) => {
|
||||||
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
|
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||||
res.status(r.status).json(r.data);
|
res.status(r.status).json(r.data);
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
// ВАЖНО: /api/v1 монтируется ДО JWT-прокси на /api — иначе прокси перехватит
|
||||||
|
// его и потребует JWT вместо ключа.
|
||||||
app.use("/api/v1", ext);
|
app.use("/api/v1", ext);
|
||||||
|
|
||||||
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
|
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
|
||||||
|
|||||||
+11
-20
@@ -352,6 +352,8 @@ body {
|
|||||||
.chip--online .chip-dot { background: var(--success-dot); }
|
.chip--online .chip-dot { background: var(--success-dot); }
|
||||||
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
|
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||||
.chip--offline .chip-dot { background: var(--neutral-dot); }
|
.chip--offline .chip-dot { background: var(--neutral-dot); }
|
||||||
|
.chip--error { background: var(--error-bg); color: var(--error-text); }
|
||||||
|
.chip--error .chip-dot { background: var(--error-dot); }
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -404,6 +406,13 @@ body {
|
|||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
||||||
|
.server-gen-chip {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
.server-card-peers {
|
.server-card-peers {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
@@ -464,22 +473,6 @@ body {
|
|||||||
}
|
}
|
||||||
.tip-wrap:hover::after { opacity: 1; }
|
.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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -664,11 +657,9 @@ tbody td:last-child { padding-right: 20px; }
|
|||||||
.server-toggle { display: flex; }
|
.server-toggle { display: flex; }
|
||||||
.server-online-chip { display: none; }
|
.server-online-chip { display: none; }
|
||||||
|
|
||||||
/* Кнопка «Добавить сервер» — полная ширина */
|
.tip-wrap { width: 100%; }
|
||||||
.tip-wrap { width: 100%; }
|
|
||||||
.server-add { width: 100%; justify-content: center; }
|
|
||||||
|
|
||||||
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */
|
/* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
|
||||||
.awg-restart { display: none; }
|
.awg-restart { display: none; }
|
||||||
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
|
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -4,7 +4,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared';
|
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
|
||||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||||
import ServerBar from './components/ServerBar';
|
import ServerBar from './components/ServerBar';
|
||||||
import { TABS } from './tabs';
|
import { TABS } from './tabs';
|
||||||
@@ -22,6 +22,7 @@ export default function App() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
||||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
||||||
const touchStartX = useRef(0);
|
const touchStartX = useRef(0);
|
||||||
@@ -36,7 +37,10 @@ export default function App() {
|
|||||||
// вкладок (юзеры, ключи) грузят сами вкладки.
|
// вкладок (юзеры, ключи) грузят сами вкладки.
|
||||||
const startSession = useCallback(async (tok: string) => {
|
const startSession = useCallback(async (tok: string) => {
|
||||||
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
||||||
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 });
|
setServerInfo({
|
||||||
|
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
|
||||||
|
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
|
||||||
|
});
|
||||||
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -107,6 +111,18 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [startSession]);
|
}, [startSession]);
|
||||||
|
|
||||||
|
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
|
||||||
|
// Если не ответил, остаётся фолбэк из констант.
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get('/ui/brand')
|
||||||
|
.then(({ data }) => setBrand({
|
||||||
|
brand: data.brand || BRAND_FALLBACK,
|
||||||
|
channel: data.channel || '',
|
||||||
|
version: data.version || '',
|
||||||
|
}))
|
||||||
|
.catch(() => { /* остаётся фолбэк */ });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Применять и сохранять тему
|
// Применять и сохранять тему
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
@@ -160,7 +176,7 @@ export default function App() {
|
|||||||
{!token ? (
|
{!token ? (
|
||||||
<div className="login-screen">
|
<div className="login-screen">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<span className="logo-name">Forgetting</span>
|
<span className="logo-name">{brand.brand}</span>
|
||||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||||
<input
|
<input
|
||||||
className="field"
|
className="field"
|
||||||
@@ -213,8 +229,10 @@ export default function App() {
|
|||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div className="sidebar-logo">
|
<div className="sidebar-logo">
|
||||||
<span className="logo-name">Forgetting</span>
|
<span className="logo-name">{brand.brand}</span>
|
||||||
<span className="sidebar-version">Alpha 0.1.3.1</span>
|
<span className="sidebar-version">
|
||||||
|
{[brand.channel, brand.version].filter(Boolean).join(' ')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<hr className="drawer-divider" />
|
<hr className="drawer-divider" />
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
@@ -243,7 +261,6 @@ export default function App() {
|
|||||||
serverOnline={isOnline}
|
serverOnline={isOnline}
|
||||||
onRestartAwg={restartAwg}
|
onRestartAwg={restartAwg}
|
||||||
restarting={restarting}
|
restarting={restarting}
|
||||||
showAddServer={chrome.addServer !== false}
|
|
||||||
showRestart={showRestart}
|
showRestart={showRestart}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
|
// 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, genLabel } from '../lib/shared';
|
||||||
|
import { IcoRefresh } from './icons';
|
||||||
|
|
||||||
import { type ServerInfo } from '../lib/shared';
|
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
|
||||||
import { IcoPlus, IcoRefresh } from './icons';
|
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
|
||||||
|
// вкладки (пиры / абоненты).
|
||||||
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
|
|
||||||
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает)
|
|
||||||
// сервер, к которому относится содержимое вкладки (пиры / API-ключи).
|
|
||||||
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
|
|
||||||
// станет несколько — карточки станут кликабельными, активная = выбранная.
|
|
||||||
export default function ServerBar({
|
export default function ServerBar({
|
||||||
serverInfo, serverOnline, onRestartAwg, restarting,
|
serverInfo, serverOnline, onRestartAwg, restarting,
|
||||||
showAddServer = true, showRestart = true,
|
showRestart = true,
|
||||||
}: {
|
}: {
|
||||||
serverInfo: ServerInfo | null;
|
serverInfo: ServerInfo | null;
|
||||||
serverOnline: boolean;
|
serverOnline: boolean;
|
||||||
onRestartAwg: () => void;
|
onRestartAwg: () => void;
|
||||||
restarting: boolean;
|
restarting: boolean;
|
||||||
showAddServer?: boolean;
|
|
||||||
showRestart?: boolean;
|
showRestart?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -36,17 +35,20 @@ export default function ServerBar({
|
|||||||
<div className="server-card-info">
|
<div className="server-card-info">
|
||||||
<span className="server-card-name">{serverInfo.name}</span>
|
<span className="server-card-name">{serverInfo.name}</span>
|
||||||
<span className="server-card-ip">{serverInfo.ip}</span>
|
<span className="server-card-ip">{serverInfo.ip}</span>
|
||||||
|
{(serverInfo.mod || serverInfo.tools) ? (
|
||||||
|
<span
|
||||||
|
className="tip-wrap server-gen-wrap"
|
||||||
|
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
|
||||||
|
>
|
||||||
|
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAddServer && (
|
|
||||||
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
|
|
||||||
<button className="server-add" disabled>
|
|
||||||
<IcoPlus /> Добавить сервер
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
||||||
{showRestart && (
|
{showRestart && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 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). Добавляя вкладку — добавь сюда
|
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
|
||||||
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
|
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
// Общие типы, константы и утилиты для всех вкладок панели.
|
// Общие типы, константы и утилиты для всех вкладок панели.
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
||||||
|
export type AwgGen = '2' | '3.1';
|
||||||
export interface User {
|
export interface User {
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
pub_key: string;
|
pub_key: string;
|
||||||
vpn_key: string;
|
vpn_key: string;
|
||||||
|
key_gen: AwgGen;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
lastHandshake: number;
|
lastHandshake: number;
|
||||||
rx?: number;
|
rx?: number;
|
||||||
@@ -14,7 +20,14 @@ export interface ServerInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
peers: number;
|
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 {
|
export interface ApiKey {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -23,10 +36,17 @@ export interface ApiKey {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
last_used: number | null;
|
last_used: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageProps {
|
export interface PageProps {
|
||||||
token: string;
|
token: string;
|
||||||
showMsg: (text: string) => void;
|
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 TOKEN_KEY = 'awg_token';
|
||||||
export const THEME_KEY = 'awg_theme';
|
export const THEME_KEY = 'awg_theme';
|
||||||
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 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 { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2026 Ivan Vasilev
|
||||||
|
This source code is licensed under the MIT license found in the
|
||||||
|
LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
|
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
|
||||||
|
|
||||||
/* Открытый API-ключ (показывается один раз при создании) */
|
/* Открытый API-ключ (показывается один раз при создании) */
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
||||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 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>/:
|
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||||
// index.tsx — компонент + логика
|
// index.tsx — компонент + логика
|
||||||
// <name>.css — стили вкладки (импортит сам компонент)
|
// <name>.css — стили вкладки (импортит сам компонент)
|
||||||
@@ -15,7 +18,6 @@ import { ICONS } from '../components/icons';
|
|||||||
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
||||||
export interface TabChrome {
|
export interface TabChrome {
|
||||||
serverBar?: boolean;
|
serverBar?: boolean;
|
||||||
addServer?: boolean;
|
|
||||||
restart?: boolean;
|
restart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import {
|
import {
|
||||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
|
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
|
||||||
type User, type PageProps,
|
type User, type PageProps, type AwgGen,
|
||||||
} from '../../lib/shared';
|
} from '../../lib/shared';
|
||||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||||
import './users.css';
|
import './users.css';
|
||||||
@@ -13,7 +16,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
|
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
|
||||||
|
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
|
||||||
|
// поколении, уже не подключатся — их надо перевыпустить.
|
||||||
|
const [serverGen, setServerGen] = useState<AwgGen>('2');
|
||||||
|
const [upgrading, setUpgrading] = useState(false);
|
||||||
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const stale = users.filter(u => u.key_gen !== serverGen);
|
||||||
|
|
||||||
const loadStats = useCallback(async (tok: string) => {
|
const loadStats = useCallback(async (tok: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -27,14 +35,56 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
const loadUsers = useCallback(async (tok: string) => {
|
const loadUsers = useCallback(async (tok: string) => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch('GET', '/api/users', tok);
|
const [data, health] = await Promise.all([
|
||||||
|
apiFetch('GET', '/api/users', tok),
|
||||||
|
apiFetch('GET', '/health', tok),
|
||||||
|
]);
|
||||||
setUsers(data.users ?? []);
|
setUsers(data.users ?? []);
|
||||||
|
setServerGen(health.gen ?? '2');
|
||||||
await loadStats(tok);
|
await loadStats(tok);
|
||||||
} catch {
|
} catch {
|
||||||
showMsg('Ошибка загрузки');
|
showMsg('Ошибка загрузки');
|
||||||
}
|
}
|
||||||
}, [loadStats, showMsg]);
|
}, [loadStats, showMsg]);
|
||||||
|
|
||||||
|
// Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса.
|
||||||
|
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
|
||||||
|
const reissueKeys = useCallback(async () => {
|
||||||
|
if (!confirm(
|
||||||
|
`Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
|
||||||
|
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
|
||||||
|
)) return;
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('POST', '/api/users/reissue', token);
|
||||||
|
showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`);
|
||||||
|
await loadUsers(token);
|
||||||
|
} catch {
|
||||||
|
showMsg('Не удалось перевыпустить ключи');
|
||||||
|
}
|
||||||
|
}, [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 () => {
|
const createUser = useCallback(async () => {
|
||||||
if (!newName) return;
|
if (!newName) return;
|
||||||
const u = await apiFetch('POST', '/api/users', token, { name: newName });
|
const u = await apiFetch('POST', '/api/users', token, { name: newName });
|
||||||
@@ -93,6 +143,16 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
|
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
|
||||||
<IcoRefresh /> Обновить
|
<IcoRefresh /> Обновить
|
||||||
</button>
|
</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})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,7 +185,20 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
: 'никогда'}
|
: 'никогда'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{u.name}</td>
|
<td>
|
||||||
|
{u.name}
|
||||||
|
{u.key_gen !== serverGen && (
|
||||||
|
<span
|
||||||
|
className="tip-wrap user-gen"
|
||||||
|
data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
|
||||||
|
>
|
||||||
|
<span className="chip chip--error">
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{genLabel(u.key_gen)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="td-mono">{u.ip}</td>
|
<td className="td-mono">{u.ip}</td>
|
||||||
<td>{bytes(u.rx)}</td>
|
<td>{bytes(u.rx)}</td>
|
||||||
<td>{bytes(u.tx)}</td>
|
<td>{bytes(u.tx)}</td>
|
||||||
@@ -159,6 +232,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
<div className="user-card-title">
|
<div className="user-card-title">
|
||||||
<span className="user-card-num">#{i + 1}</span>
|
<span className="user-card-num">#{i + 1}</span>
|
||||||
<span className="user-card-name">{u.name}</span>
|
<span className="user-card-name">{u.name}</span>
|
||||||
|
{u.key_gen !== serverGen && (
|
||||||
|
<span className="chip chip--error">
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{genLabel(u.key_gen)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
||||||
<span className="chip-dot" />
|
<span className="chip-dot" />
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
|
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
|
||||||
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
|
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
|
||||||
|
|
||||||
@@ -64,7 +69,12 @@
|
|||||||
.user-card-actions { display: flex; gap: 8px; }
|
.user-card-actions { display: flex; gap: 8px; }
|
||||||
.user-card-actions .btn--outline { flex: 1; }
|
.user-card-actions .btn--outline { flex: 1; }
|
||||||
|
|
||||||
|
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
|
||||||
|
.user-gen { margin-left: 8px; vertical-align: middle; }
|
||||||
|
|
||||||
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.user-cards-list { display: flex; }
|
.user-cards-list { display: flex; }
|
||||||
|
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
|
||||||
|
.user-gen { width: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -1 +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" />
|
/// <reference types="vite/client" />
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 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 { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
@@ -6,6 +9,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8080',
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ui': 'http://localhost:8080',
|
||||||
'/health': 'http://localhost:8080',
|
'/health': 'http://localhost:8080',
|
||||||
'/login': 'http://localhost:8080',
|
'/login': 'http://localhost:8080',
|
||||||
'/logout': 'http://localhost:8080',
|
'/logout': 'http://localhost:8080',
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.1.3+1",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+55
-11
@@ -15,19 +15,31 @@ const LOGS = path.join(ROOT, "logs");
|
|||||||
|
|
||||||
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
|
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
|
||||||
|
|
||||||
const envFile = path.join(__dirname, "..", "cli.env");
|
// Уже заданное окружение не перетираем (??=), поэтому порядок = приоритет:
|
||||||
if (existsSync(envFile)) {
|
// реальное окружение → cli.env (машинные настройки, пишет install.sh) →
|
||||||
for (const line of readFileSync(envFile, "utf8").split("\n")) {
|
// .env в корне проекта (имя панели и версия, едет в архиве релиза).
|
||||||
|
function loadEnvFile(file: string) {
|
||||||
|
if (!existsSync(file)) return;
|
||||||
|
for (const line of readFileSync(file, "utf8").split("\n")) {
|
||||||
const t = line.trim();
|
const t = line.trim();
|
||||||
if (!t || t.startsWith("#")) continue;
|
if (!t || t.startsWith("#")) continue;
|
||||||
const idx = t.indexOf("=");
|
const idx = t.indexOf("=");
|
||||||
if (idx === -1) continue;
|
if (idx === -1) continue;
|
||||||
const k = t.slice(0, idx).trim();
|
process.env[t.slice(0, idx).trim()] ??= t.slice(idx + 1).trim();
|
||||||
const v = t.slice(idx + 1).trim();
|
|
||||||
process.env[k] ??= v;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
||||||
|
|
||||||
const C = {
|
const C = {
|
||||||
@@ -71,10 +83,15 @@ const SERVICES = {
|
|||||||
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
|
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
|
||||||
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
|
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
|
||||||
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
|
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
|
||||||
|
// Панель показывает их в вордмарке (GET /ui/brand).
|
||||||
|
BRAND: BRAND,
|
||||||
|
CHANNEL: CHANNEL,
|
||||||
|
VERSION: VERSION,
|
||||||
UI_USER: process.env.UI_USER ?? "admin",
|
UI_USER: process.env.UI_USER ?? "admin",
|
||||||
UI_PASS: process.env.UI_PASS ?? "",
|
UI_PASS: process.env.UI_PASS ?? "",
|
||||||
JWT_SECRET: process.env.JWT_SECRET ?? "",
|
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",
|
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
|
||||||
},
|
},
|
||||||
pidFile: "/tmp/awg-ui.pid",
|
pidFile: "/tmp/awg-ui.pid",
|
||||||
@@ -196,7 +213,27 @@ async function restart(name: SvcName) {
|
|||||||
await start(name);
|
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("");
|
console.log("");
|
||||||
for (const name of ALL) {
|
for (const name of ALL) {
|
||||||
const running = isRunning(name);
|
const running = isRunning(name);
|
||||||
@@ -205,6 +242,13 @@ function status() {
|
|||||||
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
|
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
|
||||||
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
|
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("");
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +331,7 @@ async function interactiveMenu() {
|
|||||||
|
|
||||||
const printMenu = () => {
|
const printMenu = () => {
|
||||||
console.clear();
|
console.clear();
|
||||||
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.3.1 ──${C.reset}`)}\n`);
|
console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
|
||||||
|
|
||||||
for (const name of ALL) {
|
for (const name of ALL) {
|
||||||
const running = isRunning(name);
|
const running = isRunning(name);
|
||||||
@@ -321,7 +365,7 @@ async function interactiveMenu() {
|
|||||||
case "1": await startAll(); break;
|
case "1": await startAll(); break;
|
||||||
case "2": await stopAll(); break;
|
case "2": await stopAll(); break;
|
||||||
case "3": await restartAll(); break;
|
case "3": await restartAll(); break;
|
||||||
case "4": status(); break;
|
case "4": await status(); break;
|
||||||
case "5": await setCredentials("user"); break;
|
case "5": await setCredentials("user"); break;
|
||||||
case "6": await setCredentials("pass"); break;
|
case "6": await setCredentials("pass"); break;
|
||||||
case "0": rl.close(); process.exit(0);
|
case "0": rl.close(); process.exit(0);
|
||||||
@@ -350,7 +394,7 @@ async function main() {
|
|||||||
case "start": await startAll(); break;
|
case "start": await startAll(); break;
|
||||||
case "stop": await stopAll(); break;
|
case "stop": await stopAll(); break;
|
||||||
case "restart": await restartAll(); break;
|
case "restart": await restartAll(); break;
|
||||||
case "status": status(); break;
|
case "status": await status(); break;
|
||||||
case "credentials":
|
case "credentials":
|
||||||
if (sub === "user") await setCredentials("user");
|
if (sub === "user") await setCredentials("user");
|
||||||
else if (sub === "pass") await setCredentials("pass");
|
else if (sub === "pass") await setCredentials("pass");
|
||||||
|
|||||||
+313
-21
@@ -19,9 +19,28 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
|
|||||||
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
|
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
|
||||||
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
|
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
|
||||||
|
|
||||||
VERSION="0.1.3.1"
|
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||||
GH_REPO="maeneko/forgetting"
|
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||||
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
|
# При бампе версии правь оба места.
|
||||||
|
VERSION="0.2.1"
|
||||||
|
CHANNEL="${CHANNEL:-Beta}"
|
||||||
|
|
||||||
|
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
||||||
|
# ничего не зашито. У Gitea и GitHub путь релиза одинаковый
|
||||||
|
# (<repo>/releases/download/v<VERSION>/<file>), поэтому одна схема покрывает оба.
|
||||||
|
# REPO_BASE — основной источник (своё зеркало);
|
||||||
|
# REPO_FALLBACK — запасной, если в основном релиза ещё нет;
|
||||||
|
# ARCHIVE_URL — полный URL архива, перекрывает оба варианта;
|
||||||
|
# BRAND — имя продукта в баннерах, systemd-юните и панели; по
|
||||||
|
# умолчанию берётся из .env в архиве, здесь только запасное
|
||||||
|
# значение для баннера самого установщика.
|
||||||
|
BRAND_OVERRIDE="${BRAND:-}"
|
||||||
|
BRAND="${BRAND:-Forgetting}"
|
||||||
|
REPO_BASE="${REPO_BASE:-https://git.ma7neko.ru/maeneko/forgetting}"
|
||||||
|
REPO_FALLBACK="${REPO_FALLBACK:-https://github.com/maeneko/forgetting}"
|
||||||
|
ARCHIVE_NAME="awgcontrol-${VERSION}.tar.gz"
|
||||||
|
ARCHIVE_URL="${ARCHIVE_URL:-}"
|
||||||
|
url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; }
|
||||||
PROJECT="/opt/awg-control"
|
PROJECT="/opt/awg-control"
|
||||||
AMNEZIA_DIR="/etc/amnezia"
|
AMNEZIA_DIR="/etc/amnezia"
|
||||||
AWG_DIR="$AMNEZIA_DIR/amneziawg"
|
AWG_DIR="$AMNEZIA_DIR/amneziawg"
|
||||||
@@ -43,7 +62,7 @@ MTU="1376"
|
|||||||
TSX="$PROJECT/cli/node_modules/.bin/tsx"
|
TSX="$PROJECT/cli/node_modules/.bin/tsx"
|
||||||
CLI="$PROJECT/cli/src/index.ts"
|
CLI="$PROJECT/cli/src/index.ts"
|
||||||
|
|
||||||
echo -e "${BLD}Forgetting Alpha ${VERSION}${NC}"
|
echo -e "${BLD}${BRAND} ${CHANNEL} ${VERSION}${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой
|
# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой
|
||||||
@@ -84,10 +103,17 @@ fi
|
|||||||
|
|
||||||
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
|
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
|
||||||
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
|
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
|
||||||
if ! curl -sS --connect-timeout 8 -o /dev/null "https://github.com" 2>/dev/null; then
|
# Проверяем хосты, с которых реально качаем: хватает любого из двух —
|
||||||
NET_OK=0
|
# download_extract() умеет откатываться на запасной.
|
||||||
NET_WHY="нет доступа к github.com — проверь интернет и DNS"
|
NET_HOSTS=("$(url_host "$REPO_BASE")")
|
||||||
fi
|
[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")")
|
||||||
|
[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \
|
||||||
|
&& NET_HOSTS+=("$(url_host "$REPO_FALLBACK")")
|
||||||
|
NET_OK=0
|
||||||
|
for h in "${NET_HOSTS[@]}"; do
|
||||||
|
if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi
|
||||||
|
done
|
||||||
|
[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS"
|
||||||
|
|
||||||
# Чеклист: ✓ — пройдено, ✗ — нет.
|
# Чеклист: ✓ — пройдено, ✗ — нет.
|
||||||
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
|
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
|
||||||
@@ -118,6 +144,10 @@ echo
|
|||||||
# Ограничения, которые обязан соблюсти генератор:
|
# Ограничения, которые обязан соблюсти генератор:
|
||||||
# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU
|
# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU
|
||||||
# - S1, S2: < ~150, в части версий S1 != S2
|
# - S1, S2: < ~150, в части версий S1 != S2
|
||||||
|
# - S1–S4: при заданном HeaderProtectionKey (AmneziaWG 3.x) НИ ОДНО из них
|
||||||
|
# не может быть меньше 12 (HEADER_PROTECTION_NONCE_SIZE). Модуль на
|
||||||
|
# нарушение отвечает только «Invalid argument», причина видна лишь при
|
||||||
|
# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`
|
||||||
# - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные
|
# - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные
|
||||||
# типы сообщений WireGuard), большие uint32 без пересечений
|
# типы сообщений WireGuard), большие uint32 без пересечений
|
||||||
# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами
|
# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами
|
||||||
@@ -130,6 +160,23 @@ H2="2122053282-2133204808"
|
|||||||
H3="2133604274-2140756116"
|
H3="2133604274-2140756116"
|
||||||
H4="2143656228-2147444225"
|
H4="2143656228-2147444225"
|
||||||
|
|
||||||
|
# Поколение протокола и закрепление версии пакета — управляются переменными
|
||||||
|
# окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`.
|
||||||
|
# AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x
|
||||||
|
# или ядро старше 5.5, install.sh падает с диагностикой. Тихий откат на 2.0
|
||||||
|
# здесь нежелателен: он молча выдал бы сервер со слабой обфускацией.
|
||||||
|
# AWG_GEN=auto — старое поведение: 3.1, если условия выполняются, иначе 2.0
|
||||||
|
# без вопросов. Для установки на заведомо старое ядро/модуль.
|
||||||
|
# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0.
|
||||||
|
# Сервер, установленный на 2.0, переводится на 3.1 позже из панели
|
||||||
|
# (POST /awg/upgrade) — переустановка для этого не нужна.
|
||||||
|
AWG_GEN="${AWG_GEN:-3.1}"
|
||||||
|
[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)"
|
||||||
|
# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools}
|
||||||
|
# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым
|
||||||
|
# сервером (issue #215 — именно так словили регрессию между dkms-сборками).
|
||||||
|
AWG_PIN="${AWG_PIN:-y}"
|
||||||
|
|
||||||
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
|
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
|
||||||
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
|
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
|
||||||
|
|
||||||
@@ -170,17 +217,45 @@ start_logging() {
|
|||||||
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
|
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
|
||||||
# файлы на месте.
|
# файлы на месте.
|
||||||
download_extract() {
|
download_extract() {
|
||||||
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz"
|
local tmp="/tmp/${ARCHIVE_NAME}"
|
||||||
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz"
|
local keep_tarball=0
|
||||||
echo " → версия: ${VERSION}"
|
|
||||||
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \
|
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или
|
||||||
|| fail "Не удалось скачать архив: $url"
|
# встать без сети). Архив должен быть той же формы, что делает CI:
|
||||||
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)"
|
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/ + .env, без node_modules.
|
||||||
|
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
|
||||||
|
[[ -f "$AWG_LOCAL_TARBALL" ]] \
|
||||||
|
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
|
||||||
|
tmp="$AWG_LOCAL_TARBALL"
|
||||||
|
keep_tarball=1 # чужой файл — после распаковки не удаляем
|
||||||
|
echo " → локальный архив: $tmp"
|
||||||
|
ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)"
|
||||||
|
else
|
||||||
|
local urls=()
|
||||||
|
if [[ -n "$ARCHIVE_URL" ]]; then
|
||||||
|
urls=("$ARCHIVE_URL")
|
||||||
|
else
|
||||||
|
urls=("$REPO_BASE/releases/download/v${VERSION}/${ARCHIVE_NAME}")
|
||||||
|
[[ "$REPO_FALLBACK" != "$REPO_BASE" ]] \
|
||||||
|
&& urls+=("$REPO_FALLBACK/releases/download/v${VERSION}/${ARCHIVE_NAME}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " → версия: ${VERSION}"
|
||||||
|
local url got=""
|
||||||
|
for url in "${urls[@]}"; do
|
||||||
|
if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi
|
||||||
|
warn "Архив недоступен на $(url_host "$url") — пробуем следующий источник"
|
||||||
|
done
|
||||||
|
[[ -n "$got" ]] || fail "Не удалось скачать архив ни с одного источника:
|
||||||
|
$(printf ' %s\n' "${urls[@]}")
|
||||||
|
Укажи свой: ARCHIVE_URL=https://…/${ARCHIVE_NAME} bash install.sh"
|
||||||
|
ok "Архив скачан с $(url_host "$got"): $(du -sh "$tmp" | cut -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$PROJECT"
|
mkdir -p "$PROJECT"
|
||||||
tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|
tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|
||||||
|| fail "Не удалось распаковать архив"
|
|| fail "Не удалось распаковать архив"
|
||||||
rm -f "$tmp"
|
[[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
|
||||||
ok "Распакован → $PROJECT"
|
ok "Распакован → $PROJECT"
|
||||||
|
|
||||||
local f
|
local f
|
||||||
@@ -228,7 +303,7 @@ setup_service() {
|
|||||||
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
|
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
|
||||||
cat > "$SERVICE_UNIT" <<UNIT
|
cat > "$SERVICE_UNIT" <<UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=AWG Control — awg-ctrl + awg-ui (Forgetting)
|
Description=AWG Control — awg-ctrl + awg-ui (${BRAND})
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
@@ -414,9 +489,18 @@ else
|
|||||||
fi
|
fi
|
||||||
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
|
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
|
||||||
|
|
||||||
echo " → add-apt-repository ppa:amnezia/ppa"
|
# Если репозиторий amnezia уже настроен (например, ключ и источник заведены
|
||||||
add-apt-repository -y ppa:amnezia/ppa \
|
# руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически
|
||||||
|| fail "Не удалось добавить PPA ppa:amnezia/ppa"
|
# отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно.
|
||||||
|
# Проверяем именно файлы источников, а не `apt-cache policy`: сразу после
|
||||||
|
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
|
||||||
|
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||||
|
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
|
||||||
|
else
|
||||||
|
echo " → add-apt-repository ppa:amnezia/ppa"
|
||||||
|
add-apt-repository -y ppa:amnezia/ppa \
|
||||||
|
|| fail "Не удалось добавить PPA ppa:amnezia/ppa"
|
||||||
|
fi
|
||||||
|
|
||||||
echo " → apt-get update (после PPA)"
|
echo " → apt-get update (после PPA)"
|
||||||
apt-get update \
|
apt-get update \
|
||||||
@@ -447,6 +531,99 @@ else
|
|||||||
ok "AWG установлен, модуль amneziawg собран и загружается"
|
ok "AWG установлен, модуль amneziawg собран и загружается"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5.
|
||||||
|
# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0,
|
||||||
|
# поэтому сначала пробуем обновиться.
|
||||||
|
# - ядро: header protection использует библиотечный chacha-API
|
||||||
|
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
|
||||||
|
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
|
||||||
|
# При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий
|
||||||
|
# откат подсунул бы более слабую обфускацию незаметно для оператора.
|
||||||
|
# При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение
|
||||||
|
# по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно
|
||||||
|
# как раньше, а перейти на 3.1 можно потом из панели.
|
||||||
|
|
||||||
|
kernel_lt_5_5() {
|
||||||
|
local maj min
|
||||||
|
maj=${KERNEL%%.*}
|
||||||
|
min=${KERNEL#*.}; min=${min%%.*}
|
||||||
|
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 🛑 Версий у модуля две, и путать их нельзя:
|
||||||
|
# modinfo — что лежит на диске (после apt/DKMS);
|
||||||
|
# /sys/module/amneziawg/version — что реально загружено в ядро.
|
||||||
|
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
|
||||||
|
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
|
||||||
|
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
|
||||||
|
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
|
||||||
|
disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; }
|
||||||
|
loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; }
|
||||||
|
|
||||||
|
if [[ "$AWG_GEN" == "2.0" ]]; then
|
||||||
|
AWG3="n"
|
||||||
|
MOD_VER=$(disk_mod_ver)
|
||||||
|
ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0."
|
||||||
|
else
|
||||||
|
AWG3="y"
|
||||||
|
|
||||||
|
MOD_VER=$(disk_mod_ver)
|
||||||
|
if [[ "${MOD_VER%%.*}" != "3" ]]; then
|
||||||
|
warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить."
|
||||||
|
# Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) —
|
||||||
|
# снимаем, иначе --only-upgrade молча не тронет held-версию.
|
||||||
|
apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||||
|
apt-get update >/dev/null 2>&1 || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
|
||||||
|
apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||||
|
MOD_VER=$(disk_mod_ver)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
|
||||||
|
# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен.
|
||||||
|
LOADED_VER=$(loaded_mod_ver)
|
||||||
|
if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then
|
||||||
|
warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль."
|
||||||
|
awg-quick down "$IFACE" 2>/dev/null || true
|
||||||
|
ip link delete dev "$IFACE" 2>/dev/null || true
|
||||||
|
if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then
|
||||||
|
LOADED_VER=$(loaded_mod_ver)
|
||||||
|
ok "Модуль перезагружен: в ядре теперь $LOADED_VER"
|
||||||
|
else
|
||||||
|
warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
|
||||||
|
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
|
||||||
|
|
||||||
|
if [[ "${MOD_VER%%.*}" != "3" ]]; then
|
||||||
|
AWG3="n"
|
||||||
|
if [[ "$AWG_GEN" == "3.1" ]]; then
|
||||||
|
fail "Нужен модуль amneziawg 3.x, а в ядре версия '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)).
|
||||||
|
Перезагрузи сервер и запусти установку снова — или поставь на 2.0: AWG_GEN=auto bash install.sh"
|
||||||
|
fi
|
||||||
|
warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
|
||||||
|
[[ "$(disk_mod_ver)" == 3.* ]] && \
|
||||||
|
warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1."
|
||||||
|
elif kernel_lt_5_5; then
|
||||||
|
AWG3="n"
|
||||||
|
if [[ "$AWG_GEN" == "3.1" ]]; then
|
||||||
|
fail "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210).
|
||||||
|
Обнови ядро — или поставь на 2.0: AWG_GEN=auto bash install.sh"
|
||||||
|
fi
|
||||||
|
warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
|
||||||
|
warn "Ставим сервер на AmneziaWG 2.0."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$AWG3" == "y" ]]; then
|
||||||
|
ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)"
|
||||||
|
else
|
||||||
|
warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль — и переходи на 3.1"
|
||||||
|
warn "кнопкой «Перейти на AWG 3.1» в панели, переустановка не нужна."
|
||||||
|
fi
|
||||||
|
|
||||||
step "2/7 Node.js"
|
step "2/7 Node.js"
|
||||||
|
|
||||||
if command -v node &>/dev/null; then
|
if command -v node &>/dev/null; then
|
||||||
@@ -518,6 +695,38 @@ grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \
|
|||||||
sysctl -qp
|
sysctl -qp
|
||||||
ok "IP forwarding включён"
|
ok "IP forwarding включён"
|
||||||
|
|
||||||
|
# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента
|
||||||
|
# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились.
|
||||||
|
# RandomTrailers/DisableCookies (фичи 3.1 от 12.08.2026) пока не включаем.
|
||||||
|
# 🛑 HeaderProtectionKey фиксируется на весь срок жизни сервера наравне с
|
||||||
|
# J/S/H: он обязан совпадать на обоих концах, и его смена делает невалидными
|
||||||
|
# все ранее выданные vpn:// ключи. При KEEP_DATA берём существующий.
|
||||||
|
# Поколение предыдущей установки — нужно, чтобы предупредить о перевыпуске
|
||||||
|
# ключей при KEEP_DATA. Считать обязательно ДО перезаписи конфига.
|
||||||
|
PREV_GEN="none"
|
||||||
|
if [[ -f "$AWG_CONF" ]]; then
|
||||||
|
if grep -q '^HeaderProtectionKey *=' "$AWG_CONF"; then PREV_GEN="3.1"; else PREV_GEN="2.0"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
AWG3_LINES=""
|
||||||
|
if [[ "$AWG3" == "y" ]]; then
|
||||||
|
HEADER_PROTECTION_KEY=""
|
||||||
|
if [[ "$KEEP_DATA" == "y" && -f "$AWG_CONF" ]]; then
|
||||||
|
# sed, а не awk -F'=': base64-ключ сам содержит '=' в паддинге.
|
||||||
|
HEADER_PROTECTION_KEY=$(sed -n 's/^HeaderProtectionKey *= *//p' "$AWG_CONF" | head -1)
|
||||||
|
[[ -n "$HEADER_PROTECTION_KEY" ]] && ok "HeaderProtectionKey взят из существующего конфига"
|
||||||
|
fi
|
||||||
|
[[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk)
|
||||||
|
|
||||||
|
AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY}
|
||||||
|
ContentPaddingAddition = 10-100
|
||||||
|
RekeyAfterTime = 100-120
|
||||||
|
RekeyTimeout = 3-7
|
||||||
|
RejectAfterTime = 150-180
|
||||||
|
KeepaliveTimeout = 5-15
|
||||||
|
MaxHandshakeAttempts = 15-20"
|
||||||
|
fi
|
||||||
|
|
||||||
cat > "$AWG_CONF" <<CONF
|
cat > "$AWG_CONF" <<CONF
|
||||||
[Interface]
|
[Interface]
|
||||||
Address = ${SUBNET}.0.1/16
|
Address = ${SUBNET}.0.1/16
|
||||||
@@ -539,8 +748,18 @@ H3 = ${H3}
|
|||||||
H4 = ${H4}
|
H4 = ${H4}
|
||||||
CONF
|
CONF
|
||||||
|
|
||||||
|
[[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF"
|
||||||
|
|
||||||
chmod 600 "$AWG_CONF"
|
chmod 600 "$AWG_CONF"
|
||||||
ok "$AWG_CONF"
|
NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0)
|
||||||
|
ok "$AWG_CONF (AmneziaWG $NEW_GEN)"
|
||||||
|
|
||||||
|
if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then
|
||||||
|
warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN."
|
||||||
|
warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах"
|
||||||
|
warn "и работать перестанут. После запуска открой панель и нажми"
|
||||||
|
warn "«Перевыпустить ключи», затем раздай пользователям новые ключи."
|
||||||
|
fi
|
||||||
|
|
||||||
step "5/7 Запуск AWG"
|
step "5/7 Запуск AWG"
|
||||||
|
|
||||||
@@ -549,7 +768,44 @@ if awg show "$IFACE" &>/dev/null; then
|
|||||||
awg-quick down "$IFACE" 2>/dev/null || true
|
awg-quick down "$IFACE" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой"
|
# Подъём интерфейса — единственная настоящая проверка, что ядро приняло
|
||||||
|
# 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину
|
||||||
|
# печатает только в dmesg и только при включённом dynamic debug. Поэтому:
|
||||||
|
# упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не
|
||||||
|
# помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку.
|
||||||
|
AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *='
|
||||||
|
|
||||||
|
if ! awg-quick up "$IFACE"; then
|
||||||
|
ip link delete dev "$IFACE" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ "$AWG3" != "y" ]]; then
|
||||||
|
fail "awg-quick up $IFACE завершился с ошибкой"
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину."
|
||||||
|
echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true
|
||||||
|
|
||||||
|
if awg-quick up "$IFACE"; then
|
||||||
|
ok "Со второй попытки интерфейс поднялся"
|
||||||
|
else
|
||||||
|
ip link delete dev "$IFACE" 2>/dev/null || true
|
||||||
|
echo " ── dmesg (последние 15 строк) ─────────────────"
|
||||||
|
dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true
|
||||||
|
echo " ───────────────────────────────────────────────"
|
||||||
|
|
||||||
|
cp "$AWG_CONF" "${AWG_CONF}.awg31"
|
||||||
|
sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF"
|
||||||
|
AWG3="n"; NEW_GEN="2.0"
|
||||||
|
warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31"
|
||||||
|
[[ "$AWG_GEN" == "3.1" ]] && \
|
||||||
|
warn "Модуль и ядро подходят под 3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше."
|
||||||
|
warn "Разберись с причиной и повтори переход кнопкой «Перейти на AWG 3.1» в панели."
|
||||||
|
|
||||||
|
awg-quick up "$IFACE" \
|
||||||
|
|| fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше"
|
||||||
|
ok "Интерфейс поднят на AmneziaWG 2.0"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
|
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
|
||||||
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не
|
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не
|
||||||
@@ -567,6 +823,37 @@ else
|
|||||||
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
|
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}')
|
||||||
|
|
||||||
|
# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под
|
||||||
|
# живым сервером (issue #215: между двумя dkms-сборками словили регрессию —
|
||||||
|
# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше;
|
||||||
|
# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся.
|
||||||
|
if [[ "$AWG_PIN" == "y" ]]; then
|
||||||
|
apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||||
|
ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0")
|
||||||
|
ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")"
|
||||||
|
|
||||||
|
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
|
||||||
|
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
|
||||||
|
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
|
||||||
|
# «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет».
|
||||||
|
# Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно.
|
||||||
|
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then
|
||||||
|
echo " → UFW активен, открываем маршрут и порты"
|
||||||
|
ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \
|
||||||
|
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
|
||||||
|
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
|
||||||
|
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
|
||||||
|
ufw reload >/dev/null 2>&1 || true
|
||||||
|
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
|
||||||
|
else
|
||||||
|
echo " → UFW не активен, правила фаервола не трогаем"
|
||||||
|
fi
|
||||||
|
|
||||||
step "6/7 npm install"
|
step "6/7 npm install"
|
||||||
|
|
||||||
npm_install_all
|
npm_install_all
|
||||||
@@ -603,6 +890,11 @@ UI_PASS=${UI_PASS}
|
|||||||
JWT_SECRET=${JWT_SECRET}
|
JWT_SECRET=${JWT_SECRET}
|
||||||
ENV
|
ENV
|
||||||
|
|
||||||
|
# Имя продукта попадает в cli.env, только если оператор задал его явно
|
||||||
|
# (BRAND=… перед запуском). Иначе оно берётся из .env, приехавшего в архиве, —
|
||||||
|
# чтобы источник правды оставался один.
|
||||||
|
[[ -n "$BRAND_OVERRIDE" ]] && echo "BRAND=${BRAND_OVERRIDE}" >> "$PROJECT/cli/cli.env"
|
||||||
|
|
||||||
chmod 600 "$PROJECT/cli/cli.env"
|
chmod 600 "$PROJECT/cli/cli.env"
|
||||||
ok "$PROJECT/cli/cli.env"
|
ok "$PROJECT/cli/cli.env"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user